From 6d734b8d911e0bef23bb7298c6fe9fd472f35052 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Oct 2026 16:28:47 +1100 Subject: [PATCH 1/2] Run the tray core's tests on every platform Moves the tests of DiffEngineTray.Core into DiffEngineTray.Core.Tests (net10.0), which the unix CI job builds and runs under Release-NotWindows. DiffEngineTray.Tests keeps what needs the head, and links the helpers both use from the core's tests. What stays Windows only is marked [RunOn(OS.Windows)] with the reason in its summary: ProcessEx and FileLockKiller, a lock held by a Windows PowerShell process, and Windows' refusal to move over or delete a file open with no sharing or marked read-only. Test data is written as the platform roots a path, and DebugReportTests writes {Directory}\ for the separator everywhere, so one snapshot holds. --- claude.md | 15 +++- docs/tray.md | 8 +- src/DiffEngine.slnx | 4 + src/DiffEngine/InternalsVisibleTo.cs | 1 + .../AsyncTimerTests.cs | 0 .../DebugReportTests.Derived.verified.txt | 4 +- .../DebugReportTests.Empty.verified.txt | 0 .../DebugReportTests.Full.verified.txt | 0 .../DebugReportTests.Owned.verified.txt | 4 +- .../DebugReportTests.cs | 12 ++- .../DiffEngineTray.Core.Tests.csproj | 25 ++++++ .../DiffRunnerCanKillTest.cs | 0 .../DiffRunnerViewerFocusRaceTest.cs | 0 .../FakeLauncher.cs | 0 .../FakeViewer.cs | 0 .../FileComparerTests.cs | 0 .../FileExTest.cs | 0 .../FileLockKillerTest.cs | 5 ++ .../FileLockUtils.cs | 0 src/DiffEngineTray.Core.Tests/GlobalUsings.cs | 13 ++++ .../HeldApply.cs | 0 .../IssueLauncherTests.cs | 0 .../LinkLauncherTests.cs | 0 .../ModuleInitializer.cs | 50 ++++++++++++ .../OwnedInlineHostTest.cs | 74 ++++++++++-------- .../PiperTest.Delete.verified.txt | 0 .../PiperTest.DeleteJson.verified.txt | 0 ...iperTest.DeleteWithSourceJson.verified.txt | 0 .../PiperTest.Move.verified.txt | 0 .../PiperTest.MoveJson.verified.txt | 0 .../PiperTest.MoveWithSourceJson.verified.txt | 0 .../PiperTest.SendOnly.verified.txt | 0 .../PiperTest.cs | 0 .../ProcessExHandleTests.cs | 4 + .../ProcessExTest.cs | 6 ++ .../RecordingTracker.cs | 0 .../SerializerTests.cs | 0 ...SettingsHelperTests.ReadWrite.verified.txt | 0 .../SettingsHelperTests.cs | 0 .../SettingsWriteTests.cs | 0 .../SilentOwner.cs | 0 ...tionDirectoryFinderTests.Find.verified.txt | 0 .../SolutionDirectoryFinderTests.cs | 0 .../StubInlineHost.cs | 0 .../TrackedFilesExtensions.cs | 0 .../TrackerAcceptOpenTest.cs | 0 .../TrackerAcceptRetryTest.cs | 5 ++ .../TrackerClearTest.cs | 0 .../TrackerDeleteTest.cs | 9 +++ .../TrackerLockedMoveTest.cs | 5 ++ .../TrackerMoveOntoDeleteTest.cs | 10 +++ .../TrackerMoveTest.cs | 55 ++----------- .../TrackerProcessImageTest.cs | 5 ++ .../TrackerProcessReleaseTest.cs | 4 + .../TrackerScanTest.cs | 4 + .../TrackerSnapshotTest.cs | 0 .../TrackerSourceTest.cs | 0 .../TrackerTrackedFilesTest.cs | 51 +++--------- .../TrackerVersionTest.cs | 10 +++ .../TrayVersionFileTest.cs | 0 .../VersionReaderTest.cs | 0 src/DiffEngineTray.Core/InternalsVisibleTo.cs | 1 + .../DiffEngineTray.Tests.csproj | 9 +++ .../DiffToolLauncherTest.cs | 68 ++++++++++++++++ .../LockedFilesHandlerTest.cs | 77 +++++++++++++++++++ 65 files changed, 404 insertions(+), 134 deletions(-) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/AsyncTimerTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DebugReportTests.Derived.verified.txt (91%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DebugReportTests.Empty.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DebugReportTests.Full.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DebugReportTests.Owned.verified.txt (88%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DebugReportTests.cs (87%) create mode 100644 src/DiffEngineTray.Core.Tests/DiffEngineTray.Core.Tests.csproj rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DiffRunnerCanKillTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/DiffRunnerViewerFocusRaceTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FakeLauncher.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FakeViewer.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FileComparerTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FileExTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FileLockKillerTest.cs (95%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/FileLockUtils.cs (100%) create mode 100644 src/DiffEngineTray.Core.Tests/GlobalUsings.cs rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/HeldApply.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/IssueLauncherTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/LinkLauncherTests.cs (100%) create mode 100644 src/DiffEngineTray.Core.Tests/ModuleInitializer.cs rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/OwnedInlineHostTest.cs (95%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.Delete.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.DeleteJson.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.DeleteWithSourceJson.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.Move.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.MoveJson.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.MoveWithSourceJson.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.SendOnly.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/PiperTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/ProcessExHandleTests.cs (93%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/ProcessExTest.cs (77%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/RecordingTracker.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SerializerTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SettingsHelperTests.ReadWrite.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SettingsHelperTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SettingsWriteTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SilentOwner.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SolutionDirectoryFinderTests.Find.verified.txt (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/SolutionDirectoryFinderTests.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/StubInlineHost.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackedFilesExtensions.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerAcceptOpenTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerAcceptRetryTest.cs (90%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerClearTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerDeleteTest.cs (95%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerLockedMoveTest.cs (95%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerMoveOntoDeleteTest.cs (97%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerMoveTest.cs (66%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerProcessImageTest.cs (98%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerProcessReleaseTest.cs (96%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerScanTest.cs (98%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerSnapshotTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerSourceTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerTrackedFilesTest.cs (89%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrackerVersionTest.cs (89%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/TrayVersionFileTest.cs (100%) rename src/{DiffEngineTray.Tests => DiffEngineTray.Core.Tests}/VersionReaderTest.cs (100%) create mode 100644 src/DiffEngineTray.Tests/DiffToolLauncherTest.cs create mode 100644 src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs diff --git a/claude.md b/claude.md index 86d8ca690..5369cebf7 100644 --- a/claude.md +++ b/claude.md @@ -687,7 +687,20 @@ the comment there about not caching "nothing staged" asks for. the core's, and the question a locked file asks is the head's form (`LockedFilesHandler`). `IssueLauncher.Declined` declines in the core, which has no toolkit to ask with, and the head hands it `IssueQuestion`'s MessageBox first thing in Main. The core is signed and named in - DiffEngine's `InternalsVisibleTo`, and names the head and the tests in its own + DiffEngine's `InternalsVisibleTo`, and names the head and both test projects in its own +- The core's tests are `DiffEngineTray.Core.Tests` (net10.0), which runs in the unix CI job as + well as on Windows. `DiffEngineTray.Tests` keeps what needs the head: the menu, the forms, the + hot keys, `LockedFilesHandler`, `DiffToolLauncher` and `TrayViewerSyncTest`. The helpers both + use (`RecordingTracker`, `FakeViewer`, `FileLockUtils` and the rest) live with the core's tests + and are linked into the head's. What stays Windows only is marked `[RunOn(OS.Windows)]` with + the reason in its summary, and is of three kinds: `ProcessEx` and `FileLockKiller`, a lock held + by a Windows PowerShell process, and Windows' file semantics, under which a file open with no + sharing or marked read-only cannot be moved over or deleted. On Unix a rename or an unlink + takes no notice of either. Test data is written as the platform roots a path + (`OwnedInlineHostTest.Repo`): a Windows path read on Linux is one file name of the whole + string, so every entry had the same name, and a viewer at `C:\tools\DiffEngineViewer.exe` was + not the viewer. `DebugReportTests` writes `{Directory}\` for the separator on every platform, + so one snapshot holds for all of them - `PiperServer` - TCP server (localhost, 3492) receiving move/delete payloads from DiffEngine. Deliberately a second listener beside the viewer protocol, not debt: its format is frozen (every stable DiffEngine embeds PiperClient, pinned in test projects while the tray updates diff --git a/docs/tray.md b/docs/tray.md index 9be642b8c..c51431a7c 100644 --- a/docs/tray.md +++ b/docs/tray.md @@ -202,7 +202,7 @@ The case it exists for is a test suite that needs the launch to happen but does "ProcessId":1000 } ``` -snippet source | anchor +snippet source | anchor @@ -216,7 +216,7 @@ The case it exists for is a test suite that needs the launch to happen but does "File":"theFilePath" } ``` -snippet source | anchor +snippet source | anchor @@ -238,7 +238,7 @@ A move or a delete of a file that was [derived from a document](/docs/viewer.md# "Source":"theSourceTempFilePath" } ``` -snippet source | anchor +snippet source | anchor @@ -250,7 +250,7 @@ A move or a delete of a file that was [derived from a document](/docs/viewer.md# "Source":"theSourceTempFilePath" } ``` -snippet source | anchor +snippet source | anchor 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. diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx index 54bfc9baa..564463277 100644 --- a/src/DiffEngine.slnx +++ b/src/DiffEngine.slnx @@ -88,6 +88,10 @@ + + + + + + net10.0 + $(NoWarn);CA1416;NU1902 + Exe + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs b/src/DiffEngineTray.Core.Tests/DiffRunnerCanKillTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs rename to src/DiffEngineTray.Core.Tests/DiffRunnerCanKillTest.cs diff --git a/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs b/src/DiffEngineTray.Core.Tests/DiffRunnerViewerFocusRaceTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs rename to src/DiffEngineTray.Core.Tests/DiffRunnerViewerFocusRaceTest.cs diff --git a/src/DiffEngineTray.Tests/FakeLauncher.cs b/src/DiffEngineTray.Core.Tests/FakeLauncher.cs similarity index 100% rename from src/DiffEngineTray.Tests/FakeLauncher.cs rename to src/DiffEngineTray.Core.Tests/FakeLauncher.cs diff --git a/src/DiffEngineTray.Tests/FakeViewer.cs b/src/DiffEngineTray.Core.Tests/FakeViewer.cs similarity index 100% rename from src/DiffEngineTray.Tests/FakeViewer.cs rename to src/DiffEngineTray.Core.Tests/FakeViewer.cs diff --git a/src/DiffEngineTray.Tests/FileComparerTests.cs b/src/DiffEngineTray.Core.Tests/FileComparerTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/FileComparerTests.cs rename to src/DiffEngineTray.Core.Tests/FileComparerTests.cs diff --git a/src/DiffEngineTray.Tests/FileExTest.cs b/src/DiffEngineTray.Core.Tests/FileExTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/FileExTest.cs rename to src/DiffEngineTray.Core.Tests/FileExTest.cs diff --git a/src/DiffEngineTray.Tests/FileLockKillerTest.cs b/src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs similarity index 95% rename from src/DiffEngineTray.Tests/FileLockKillerTest.cs rename to src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs index af20ac7ff..437238f6b 100644 --- a/src/DiffEngineTray.Tests/FileLockKillerTest.cs +++ b/src/DiffEngineTray.Core.Tests/FileLockKillerTest.cs @@ -1,3 +1,8 @@ +/// +/// Windows only: FileLockKiller asks the restart manager who holds a file, and the lock is held by +/// a Windows PowerShell process. A Unix head that kills lockers will need a way of its own to ask. +/// +[RunOn(TUnit.Core.Enums.OS.Windows)] public class FileLockKillerTest { [Test] diff --git a/src/DiffEngineTray.Tests/FileLockUtils.cs b/src/DiffEngineTray.Core.Tests/FileLockUtils.cs similarity index 100% rename from src/DiffEngineTray.Tests/FileLockUtils.cs rename to src/DiffEngineTray.Core.Tests/FileLockUtils.cs diff --git a/src/DiffEngineTray.Core.Tests/GlobalUsings.cs b/src/DiffEngineTray.Core.Tests/GlobalUsings.cs new file mode 100644 index 000000000..1a0a642a0 --- /dev/null +++ b/src/DiffEngineTray.Core.Tests/GlobalUsings.cs @@ -0,0 +1,13 @@ +global using System.Diagnostics; +global using System.Net; +global using System.Net.Sockets; +global using System.Security.AccessControl; +global using System.Security.Principal; +global using EmptyFiles; + +[assembly: ParallelLimiter] + +public class SingleThreadedLimit : TUnit.Core.Interfaces.IParallelLimit +{ + public int Limit => 1; +} diff --git a/src/DiffEngineTray.Tests/HeldApply.cs b/src/DiffEngineTray.Core.Tests/HeldApply.cs similarity index 100% rename from src/DiffEngineTray.Tests/HeldApply.cs rename to src/DiffEngineTray.Core.Tests/HeldApply.cs diff --git a/src/DiffEngineTray.Tests/IssueLauncherTests.cs b/src/DiffEngineTray.Core.Tests/IssueLauncherTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/IssueLauncherTests.cs rename to src/DiffEngineTray.Core.Tests/IssueLauncherTests.cs diff --git a/src/DiffEngineTray.Tests/LinkLauncherTests.cs b/src/DiffEngineTray.Core.Tests/LinkLauncherTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/LinkLauncherTests.cs rename to src/DiffEngineTray.Core.Tests/LinkLauncherTests.cs diff --git a/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs b/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs new file mode 100644 index 000000000..44c77f06c --- /dev/null +++ b/src/DiffEngineTray.Core.Tests/ModuleInitializer.cs @@ -0,0 +1,50 @@ +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + DeclineToOpenAnIssue(); + MachineSettings.Ignore(); + KeepEnvironmentWritesInProcess(); + PointAtAClosedPort(); + } + + /// + /// The tray follows an error it did not expect by offering to open an issue on GitHub, and a + /// yes opens a browser. A test that reaches the offer is declined here, with nobody asked, and + /// what it reached is kept for the tests that are about an error being reported. + /// + static void DeclineToOpenAnIssue() => + IssueLauncher.Declined = text => + { + IssuesAsked.Enqueue(text); + return true; + }; + + /// + /// Every question has declined, oldest first. + /// + internal static ConcurrentQueue IssuesAsked { get; } = new(); + + /// + /// Tests must not write the user environment of the machine running them. The test projects + /// run as parallel processes over the one registry key, so a capture in one and a restore in + /// the other race, and the value that loses is gone. + /// + static void KeepEnvironmentWritesInProcess() => + EnvironmentHelper.Set = EnvironmentHelper.SetProcessOnly; + + /// + /// The tray asks the viewer for pending snapshots. Without this a DiffEngineViewer running on + /// the developer's machine would answer, and tests that expect nothing pending would see its + /// queue. FakeViewer overrides this for the tests that do want a viewer. + /// + static void PointAtAClosedPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint) listener.LocalEndpoint).Port; + listener.Stop(); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + } +} diff --git a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs b/src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs similarity index 95% rename from src/DiffEngineTray.Tests/OwnedInlineHostTest.cs rename to src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs index f10fae37e..bf1bb5df9 100644 --- a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs +++ b/src/DiffEngineTray.Core.Tests/OwnedInlineHostTest.cs @@ -6,6 +6,16 @@ /// public class OwnedInlineHostTest { + /// + /// A source file in a repository that need not exist, rooted as this platform roots a path. + /// The queue names an entry by its source's file name, and a Windows path read on Linux is one + /// file name of the whole string, so every entry was named c:\repo\SampleTests.cs there. + /// + static string Repo(string file) => + Path.Combine(repo, file); + + static readonly string repo = OperatingSystem.IsWindows() ? @"c:\repo" : "/repo"; + sealed class Owner : IDisposable { public Owner(Func? applier = null) @@ -32,12 +42,12 @@ public ViewerResponse Send(ViewerMessage message, TimeSpan? wait = null) } public ViewerResponse Queue( - string source = @"c:\repo\SampleTests.cs", + string? source = null, int line = 42, string content = "new", string? framework = null, string? testName = null) => - Send(new(ViewerVerb.Inline, Body: InlinePatchFile.Build(new(source, line, "\"old\"", content) + Send(new(ViewerVerb.Inline, Body: InlinePatchFile.Build(new(source ?? Repo("SampleTests.cs"), line, "\"old\"", content) { Framework = framework, TestName = testName @@ -110,7 +120,7 @@ public async Task APatchArrivingOnceTheSessionIsEndingIsRefused() owner.Queue(); owner.Host.SessionEnding(); - var response = owner.Queue(@"c:\repo\OtherTests.cs", 7); + var response = owner.Queue(Repo("OtherTests.cs"), 7); await Assert.That(response.Ok).IsFalse(); await Assert.That(response.Message).IsEqualTo("This tray is going with the session and can take nothing more."); @@ -139,7 +149,7 @@ public async Task ASecondPatchDoesNotStartASecondViewer() using var owner = new Owner(); owner.Queue(); - owner.Queue(@"c:\repo\OtherTests.cs", 7); + owner.Queue(Repo("OtherTests.cs"), 7); await Assert.That(owner.Launcher.Launches).IsEqualTo(1); await Assert.That(owner.Host.List().Count).IsEqualTo(2); @@ -152,7 +162,7 @@ public async Task AViewerThatWentAwayIsStartedAgain() owner.Queue(); owner.Launcher.Running = false; - owner.Queue(@"c:\repo\OtherTests.cs", 7); + owner.Queue(Repo("OtherTests.cs"), 7); await Assert.That(owner.Launcher.Launches).IsEqualTo(2); } @@ -176,7 +186,7 @@ public async Task APassingRerunSettlesTheEntry() using var owner = new Owner(); owner.Queue(); - var response = owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42))); + var response = owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42))); await Assert.That(response.Ok).IsTrue(); await Assert.That(owner.Host.List()).IsEmpty(); @@ -370,7 +380,7 @@ public async Task DiscardAllEmptiesTheQueue() { using var owner = new Owner(); owner.Queue(); - owner.Queue(@"c:\repo\OtherTests.cs", 7); + owner.Queue(Repo("OtherTests.cs"), 7); owner.Host.DiscardAll(out _); @@ -549,7 +559,7 @@ public async Task AConflictedAcceptIsRefusedOverTheWire() owner.Queue(content: "eight", framework: "net8.0"); owner.Queue(content: "nine", framework: "net9.0"); - var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(@"c:\repo\SampleTests.cs", 42))); + var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(Repo("SampleTests.cs"), 42))); await Assert.That(response.Ok).IsFalse(); await Assert.That(response.Message).IsEqualTo("Conflicting snapshots (net8.0 / net9.0), resolve in the viewer"); @@ -572,7 +582,7 @@ public async Task AnAcceptWithAnOriginBodyAppliesThatVariant() owner.Queue(content: "eight", framework: "net8.0"); owner.Queue(content: "nine", framework: "net9.0"); - var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net9.0")); + var response = owner.Send(new(ViewerVerb.Accept, InlineKey.For(Repo("SampleTests.cs"), 42), "net9.0")); await Assert.That(response.Ok).IsTrue(); await Assert.That(applied.Single().NewContent).IsEqualTo("nine"); @@ -583,7 +593,7 @@ public async Task AnAcceptWithAnOriginBodyAppliesThatVariant() public async Task AcceptAllOverTheWireSkipsConflicts() { using var owner = new Owner(_ => InlineApplyResult.Applied); - owner.Queue(@"c:\repo\OtherTests.cs", 7); + owner.Queue(Repo("OtherTests.cs"), 7); owner.Queue(content: "eight", framework: "net8.0"); owner.Queue(content: "nine", framework: "net9.0"); @@ -600,10 +610,10 @@ public async Task ASettleWithABodyRemovesOneVariant() owner.Queue(content: "eight", framework: "net8.0"); owner.Queue(content: "nine", framework: "net9.0"); - owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net8.0")); + owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42), "net8.0")); await Assert.That(owner.Host.List().Single().Status).IsNull(); - owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 42), "net9.0")); + owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 42), "net9.0")); await Assert.That(owner.Host.List()).IsEmpty(); } @@ -814,7 +824,7 @@ public async Task AListingDuringAnAcceptAllSaysHowFarItHasGot() { using var held = new HeldApply(2); using var owner = new Owner(held.Apply); - owner.Queue(@"c:\repo\OtherTests.cs", 7); + owner.Queue(Repo("OtherTests.cs"), 7); owner.Queue(); var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); @@ -842,9 +852,9 @@ public async Task TheSnapshotsOfOneFileAreCompletedTogether() { using var held = new HeldApply(2); using var owner = new Owner(held.Apply); - owner.Queue(@"c:\repo\SampleTests.cs", 7); - owner.Queue(@"c:\repo\SampleTests.cs", 9); - owner.Queue(@"c:\repo\OtherTests.cs", 3); + owner.Queue(Repo("SampleTests.cs"), 7); + owner.Queue(Repo("SampleTests.cs"), 9); + owner.Queue(Repo("OtherTests.cs"), 3); var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); @@ -871,15 +881,15 @@ public async Task EachSnapshotOfAFileHasItsOwnOutcome() _ => _.LineHint == 9 ? InlineApplyResult.Failed("the file is held") : InlineApplyResult.Applied); - owner.Queue(@"c:\repo\SampleTests.cs", 7); - owner.Queue(@"c:\repo\SampleTests.cs", 9); - owner.Queue(@"c:\repo\SampleTests.cs", 11); + owner.Queue(Repo("SampleTests.cs"), 7); + owner.Queue(Repo("SampleTests.cs"), 9); + owner.Queue(Repo("SampleTests.cs"), 11); var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); await Assert.That(response.Message).IsEqualTo("Accepted 2, 1 failed. the file is held"); var left = owner.Send(new(ViewerVerb.ListFull)).Items.Single(); - await Assert.That(left.Key).IsEqualTo(InlineKey.For(@"c:\repo\SampleTests.cs", 9)); + await Assert.That(left.Key).IsEqualTo(InlineKey.For(Repo("SampleTests.cs"), 9)); } /// @@ -898,24 +908,24 @@ public async Task WhatIsLeftOfAFileGoesToWhereItsCallSitesAre() 9 => InlineApplyResult.Failed("the file is held"), _ => InlineApplyResult.Applied }); - owner.Queue(@"c:\repo\SampleTests.cs", 7); - owner.Queue(@"c:\repo\SampleTests.cs", 9); - owner.Queue(@"c:\repo\OtherTests.cs", 11); + owner.Queue(Repo("SampleTests.cs"), 7); + owner.Queue(Repo("SampleTests.cs"), 9); + owner.Queue(Repo("OtherTests.cs"), 11); var accepted = owner.Host.Accept(owner.Host.List()[0], out _); await Assert.That(accepted).IsEqualTo(AcceptOutcome.Applied); await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Items.Select(_ => _.Key)) - .IsEquivalentTo([InlineKey.For(@"c:\repo\SampleTests.cs", 13), InlineKey.For(@"c:\repo\OtherTests.cs", 11)]); + .IsEquivalentTo([InlineKey.For(Repo("SampleTests.cs"), 13), InlineKey.For(Repo("OtherTests.cs"), 11)]); - owner.Queue(@"c:\repo\SampleTests.cs", 7); - owner.Queue(@"c:\repo\SampleTests.cs", 9); + owner.Queue(Repo("SampleTests.cs"), 7); + owner.Queue(Repo("SampleTests.cs"), 9); var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); // The one at 13 is applied, and the one at 9 is not and is four lines down await Assert.That(response.Message).IsEqualTo("Accepted 3, 1 failed. the file is held"); var left = owner.Send(new(ViewerVerb.ListFull)).Items.Single(); - await Assert.That(left.Key).IsEqualTo(InlineKey.For(@"c:\repo\SampleTests.cs", 13)); + await Assert.That(left.Key).IsEqualTo(InlineKey.For(Repo("SampleTests.cs"), 13)); await Assert.That(left.Status).IsEqualTo("the file is held"); } @@ -1083,12 +1093,12 @@ public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten() return held.Apply(patch); }); - owner.Queue(@"c:\repo\SampleTests.cs", 1); - owner.Queue(@"c:\repo\OtherTests.cs", 2); + owner.Queue(Repo("SampleTests.cs"), 1); + owner.Queue(Repo("OtherTests.cs"), 2); var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); - owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\OtherTests.cs", 2))); + owner.Send(new(ViewerVerb.Discard, InlineKey.For(Repo("OtherTests.cs"), 2))); held.Release(); await accepting; @@ -1123,7 +1133,7 @@ public async Task AnEntryDiscardedWhileItsOwnFileIsWaitedForIsNotWritten() var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); - owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\SampleTests.cs", 2))); + owner.Send(new(ViewerVerb.Discard, InlineKey.For(Repo("SampleTests.cs"), 2))); held.Release(); var response = await accepting; @@ -1158,7 +1168,7 @@ public async Task AnEntrySettledWhileItsOwnFileIsWaitedForIsNotWritten() var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); - owner.Send(new(ViewerVerb.Settle, InlineKey.For(@"c:\repo\SampleTests.cs", 2))); + owner.Send(new(ViewerVerb.Settle, InlineKey.For(Repo("SampleTests.cs"), 2))); held.Release(); var response = await accepting; diff --git a/src/DiffEngineTray.Tests/PiperTest.Delete.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.Delete.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.Delete.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.Delete.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.DeleteJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.DeleteJson.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.DeleteJson.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.DeleteJson.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.DeleteWithSourceJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.DeleteWithSourceJson.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.DeleteWithSourceJson.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.DeleteWithSourceJson.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.Move.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.Move.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.Move.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.Move.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.MoveJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.MoveJson.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.MoveJson.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.MoveJson.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.MoveWithSourceJson.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.MoveWithSourceJson.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.MoveWithSourceJson.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.MoveWithSourceJson.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt b/src/DiffEngineTray.Core.Tests/PiperTest.SendOnly.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt rename to src/DiffEngineTray.Core.Tests/PiperTest.SendOnly.verified.txt diff --git a/src/DiffEngineTray.Tests/PiperTest.cs b/src/DiffEngineTray.Core.Tests/PiperTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/PiperTest.cs rename to src/DiffEngineTray.Core.Tests/PiperTest.cs diff --git a/src/DiffEngineTray.Tests/ProcessExHandleTests.cs b/src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs similarity index 93% rename from src/DiffEngineTray.Tests/ProcessExHandleTests.cs rename to src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs index 89e5f65f4..a2a1a83eb 100644 --- a/src/DiffEngineTray.Tests/ProcessExHandleTests.cs +++ b/src/DiffEngineTray.Core.Tests/ProcessExHandleTests.cs @@ -11,8 +11,12 @@ /// ended. That is the same thing as the id being held, since Windows will not hand out an id while /// a handle to it exists. /// +/// +/// Windows only, as ProcessEx is: the handle is kernel32's. +/// /// [NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] public class ProcessExHandleTests { [Test] diff --git a/src/DiffEngineTray.Tests/ProcessExTest.cs b/src/DiffEngineTray.Core.Tests/ProcessExTest.cs similarity index 77% rename from src/DiffEngineTray.Tests/ProcessExTest.cs rename to src/DiffEngineTray.Core.Tests/ProcessExTest.cs index 6ff768de5..b5387da25 100644 --- a/src/DiffEngineTray.Tests/ProcessExTest.cs +++ b/src/DiffEngineTray.Core.Tests/ProcessExTest.cs @@ -1,6 +1,10 @@ public class ProcessExTest { + /// + /// Windows only, as TryGet is: it opens the process through kernel32. + /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task TryGet() { using var current = Process.GetCurrentProcess(); @@ -8,7 +12,9 @@ public async Task TryGet() found!.Dispose(); } + /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task TryGetMissing() { await Assert.That(ProcessEx.TryGet(40000, out var found)).IsFalse(); diff --git a/src/DiffEngineTray.Tests/RecordingTracker.cs b/src/DiffEngineTray.Core.Tests/RecordingTracker.cs similarity index 100% rename from src/DiffEngineTray.Tests/RecordingTracker.cs rename to src/DiffEngineTray.Core.Tests/RecordingTracker.cs diff --git a/src/DiffEngineTray.Tests/SerializerTests.cs b/src/DiffEngineTray.Core.Tests/SerializerTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/SerializerTests.cs rename to src/DiffEngineTray.Core.Tests/SerializerTests.cs diff --git a/src/DiffEngineTray.Tests/SettingsHelperTests.ReadWrite.verified.txt b/src/DiffEngineTray.Core.Tests/SettingsHelperTests.ReadWrite.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/SettingsHelperTests.ReadWrite.verified.txt rename to src/DiffEngineTray.Core.Tests/SettingsHelperTests.ReadWrite.verified.txt diff --git a/src/DiffEngineTray.Tests/SettingsHelperTests.cs b/src/DiffEngineTray.Core.Tests/SettingsHelperTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/SettingsHelperTests.cs rename to src/DiffEngineTray.Core.Tests/SettingsHelperTests.cs diff --git a/src/DiffEngineTray.Tests/SettingsWriteTests.cs b/src/DiffEngineTray.Core.Tests/SettingsWriteTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/SettingsWriteTests.cs rename to src/DiffEngineTray.Core.Tests/SettingsWriteTests.cs diff --git a/src/DiffEngineTray.Tests/SilentOwner.cs b/src/DiffEngineTray.Core.Tests/SilentOwner.cs similarity index 100% rename from src/DiffEngineTray.Tests/SilentOwner.cs rename to src/DiffEngineTray.Core.Tests/SilentOwner.cs diff --git a/src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.Find.verified.txt b/src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.Find.verified.txt similarity index 100% rename from src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.Find.verified.txt rename to src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.Find.verified.txt diff --git a/src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.cs b/src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.cs similarity index 100% rename from src/DiffEngineTray.Tests/SolutionDirectoryFinderTests.cs rename to src/DiffEngineTray.Core.Tests/SolutionDirectoryFinderTests.cs diff --git a/src/DiffEngineTray.Tests/StubInlineHost.cs b/src/DiffEngineTray.Core.Tests/StubInlineHost.cs similarity index 100% rename from src/DiffEngineTray.Tests/StubInlineHost.cs rename to src/DiffEngineTray.Core.Tests/StubInlineHost.cs diff --git a/src/DiffEngineTray.Tests/TrackedFilesExtensions.cs b/src/DiffEngineTray.Core.Tests/TrackedFilesExtensions.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrackedFilesExtensions.cs rename to src/DiffEngineTray.Core.Tests/TrackedFilesExtensions.cs diff --git a/src/DiffEngineTray.Tests/TrackerAcceptOpenTest.cs b/src/DiffEngineTray.Core.Tests/TrackerAcceptOpenTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrackerAcceptOpenTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerAcceptOpenTest.cs diff --git a/src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs b/src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs similarity index 90% rename from src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs index df1ee93a7..534e50d20 100644 --- a/src/DiffEngineTray.Tests/TrackerAcceptRetryTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerAcceptRetryTest.cs @@ -28,7 +28,12 @@ public async Task TransientLock_RetriesUntilAccepted() await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new"); } + /// + /// Windows only: a file open with no sharing cannot be moved over there. On Unix the lock is + /// advisory, and a rename takes no notice of it. + /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task UnresolvedLock_KeepsMovePendingAndNotifies() { TrackedMove? failed = null; diff --git a/src/DiffEngineTray.Tests/TrackerClearTest.cs b/src/DiffEngineTray.Core.Tests/TrackerClearTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrackerClearTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerClearTest.cs diff --git a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs b/src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs similarity index 95% rename from src/DiffEngineTray.Tests/TrackerDeleteTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs index 038f90ded..a6530b8f5 100644 --- a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerDeleteTest.cs @@ -85,8 +85,13 @@ public async Task AcceptSingle_NotEmpty() /// read-only or open verified file threw out of the click handler - onto the UI thread, where /// nothing hooks Application.ThreadException - and the entry had already been untracked, so /// the pending delete went with it. + /// + /// Windows only: a file open with no sharing cannot be deleted there. On Unix the lock is + /// advisory, and an unlink takes no notice of it. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task AcceptLeavesAnUndeletableFileTracked() { await using var tracker = new RecordingTracker(); @@ -104,8 +109,12 @@ public async Task AcceptLeavesAnUndeletableFileTracked() /// /// And one bad delete does not stop the rest of Accept all. Unguarded, the throw skipped the /// remaining deletes, every pending move, and the snapshots. + /// + /// Windows only, for the reason above. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task AcceptAllContinuesPastAnUndeletableFile() { await using var tracker = new RecordingTracker(); diff --git a/src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs b/src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs similarity index 95% rename from src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs index 7ffbdcdb9..32988fed9 100644 --- a/src/DiffEngineTray.Tests/TrackerLockedMoveTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerLockedMoveTest.cs @@ -1,3 +1,8 @@ +/// +/// Windows only: a file another process has open cannot be moved over there, which is the lock +/// these are about, and the restart manager is what says who holds it. On Unix the move succeeds. +/// +[RunOn(TUnit.Core.Enums.OS.Windows)] public class TrackerLockedMoveTest : IDisposable { diff --git a/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs b/src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs similarity index 97% rename from src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs index 971acefdd..8acb97ba6 100644 --- a/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerMoveOntoDeleteTest.cs @@ -251,8 +251,13 @@ public async Task ADeleteWaitingOnAMoveIsCarriedOutOnceTheMoveIsDiscarded() /// delete on its target was held by nothing meanwhile: not by a pending move, which had left, /// and not by a write, which had yet to be marked. Asked from where a refused move is reported, /// which is inside the accept, before the move is put back. + /// + /// Windows only: a read-only file cannot be moved over there. On Unix a rename needs only the + /// directory to be writable. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task ADeleteIsHeldWhileTheMoveOntoItsFileIsBeingAccepted() { RecordingTracker? tracker = null; @@ -352,8 +357,13 @@ public async Task TheWireSweepHoldsADeleteOfTheFileItsMoveJustWrote() /// /// A move that could not be carried out is still going to write the file, so the delete waits /// for it as well. Deleting here left the old snapshot gone and the new one not yet in place. + /// + /// Windows only: a file open with no sharing cannot be moved there. On Unix the lock is + /// advisory, and a rename takes no notice of it. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task TheWireSweepHoldsADeleteOfAFileAMoveStillPendingTargets() { await using var tracker = new RecordingTracker(); diff --git a/src/DiffEngineTray.Tests/TrackerMoveTest.cs b/src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs similarity index 66% rename from src/DiffEngineTray.Tests/TrackerMoveTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs index e85a4d862..8f7821a54 100644 --- a/src/DiffEngineTray.Tests/TrackerMoveTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerMoveTest.cs @@ -20,7 +20,12 @@ public async Task AddMultiple() await Assert.That(tracker.TrackingAny).IsTrue(); } + /// + /// Windows only: the tool is a Windows PowerShell process, and a move's process is tracked + /// only where its image and command line can be read. + /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task AddSame() { await using var tracker = new RecordingTracker(); @@ -112,54 +117,4 @@ public void Dispose() string file1 = Path.GetTempFileName(); string file2 = Path.GetTempFileName(); string file3 = Path.GetTempFileName(); - - /// - /// DiffRunner resends a move for the same received file on every re-run, with a process - /// id each time. The update factory disposes the Process of the move it replaces and leaves it - /// there, so a menu opened before that re-run holds a move whose process is disposed, and its - /// "Open diff tool" item throws on the UI thread. - /// - [Test] - public async Task OpenDiffToolFromAMenuBuiltBeforeTheMoveWasUpdated() - { - await using var tracker = new RecordingTracker(); - var temp = file1; - var target = file2; - var toolLock = file3; - // Stands in for an auto refresh diff tool that is still open, which is what DiffRunner - // resends the id of - var tool = FileLockUtils.StartFileLockProcess(toolLock, shows: temp); - try - { - // Nothing at this path exists, so if the launcher gets past the process it starts - // nothing. Named as the tool is, since a process running something else is not tracked - var exe = Path.Combine( - Path.GetTempPath(), - $"ReviewReproNoSuchTool_{Guid.NewGuid()}", - Path.GetFileName(tool.MainModule!.FileName)); - - // What the menu captured when it opened - var shown = tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id); - await Assert.That(shown.Process).IsNotNull(); - // The re-run's move, landing while that menu is open - tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id); - - Exception? thrown = null; - try - { - // The "Open diff tool" item's click handler - DiffToolLauncher.Launch(shown); - } - catch (Exception exception) - { - thrown = exception; - } - - await Assert.That(thrown).IsNull(); - } - finally - { - FileLockUtils.Cleanup(tool); - } - } } diff --git a/src/DiffEngineTray.Tests/TrackerProcessImageTest.cs b/src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs similarity index 98% rename from src/DiffEngineTray.Tests/TrackerProcessImageTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs index 0ddff2910..c2685e87b 100644 --- a/src/DiffEngineTray.Tests/TrackerProcessImageTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerProcessImageTest.cs @@ -8,8 +8,13 @@ /// Every process here is started by the test, and stands in both for a diff tool and for the /// stranger that got a diff tool's id. /// +/// +/// Windows only: an image and a command line are read through kernel32 and ntdll, and with +/// neither a move is tracked with no process at all. +/// /// [NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] public class TrackerProcessImageTest : IDisposable { diff --git a/src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs b/src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs similarity index 96% rename from src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs index 3faa8be9b..c2443510c 100644 --- a/src/DiffEngineTray.Tests/TrackerProcessReleaseTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerProcessReleaseTest.cs @@ -7,8 +7,12 @@ /// Every process here is one the test started, and none of them may be ended by the tray: the /// move says it cannot be killed. /// +/// +/// Windows only: the tool is a Windows PowerShell process, and the handle is kernel32's. +/// /// [NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] public class TrackerProcessReleaseTest : IDisposable { diff --git a/src/DiffEngineTray.Tests/TrackerScanTest.cs b/src/DiffEngineTray.Core.Tests/TrackerScanTest.cs similarity index 98% rename from src/DiffEngineTray.Tests/TrackerScanTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerScanTest.cs index 1cbd823e6..3ed7f9f1b 100644 --- a/src/DiffEngineTray.Tests/TrackerScanTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerScanTest.cs @@ -70,8 +70,12 @@ public async Task WhatAMoveWasFoundToBeIsForgottenOnceItHasLeft() /// A file that may not be read is no more a failure of the scan than one that is locked: the /// pair cannot be compared this round. Only the locked one was caught, so this one failed the /// whole scan, every two seconds, for as long as the move stayed pending. + /// + /// Windows only: the read is denied by an access control entry. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task ATargetThatMayNotBeReadIsPassedOver() { await using var tracker = new RecordingTracker(); diff --git a/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs b/src/DiffEngineTray.Core.Tests/TrackerSnapshotTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrackerSnapshotTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerSnapshotTest.cs diff --git a/src/DiffEngineTray.Tests/TrackerSourceTest.cs b/src/DiffEngineTray.Core.Tests/TrackerSourceTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrackerSourceTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerSourceTest.cs diff --git a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs b/src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs similarity index 89% rename from src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs index edcb9ed66..7d77fac80 100644 --- a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerTrackedFilesTest.cs @@ -79,8 +79,13 @@ public async Task AcceptingATrackedMoveMovesTheFile() /// /// Wire accepts run on a listener thread with nobody at a dialog, so a locked move is refused /// with a pointer at the tray menu and the resolver is never consulted. + /// + /// Windows only: a file open with no sharing cannot be moved over there. On Unix the lock is + /// advisory, and a rename takes no notice of it. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task ALockedMoveIsRefusedWithoutPrompting() { await using var tracker = new RecordingTracker( @@ -280,8 +285,13 @@ public async Task AMoveArrivingAgainOverTheViewerPortKeepsItsTool() /// /// The same for any other tool: what the sender said about it, and the process it started, /// are still the tool showing the pair. + /// + /// Windows only: the tool is a Windows PowerShell process, and a move's process is tracked + /// only where its image and command line can be read. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task AMoveArrivingAgainOverTheViewerPortKeepsAnotherToolAndItsProcess() { await using var tracker = new RecordingTracker(); @@ -371,45 +381,4 @@ public TrackerTrackedFilesTest() temp = Path.Combine(tempDirectory, "Sample.Test.received.txt"); target = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}.verified.txt"); } - - /// - /// With "Always kill locking processes" on, the menu accepts a locked move by killing - /// the locker without asking. The same accept arriving from the viewer is refused as locked, - /// because the preference lives inside the resolver and a wire accept never consults it. - /// - [Test] - public async Task AlwaysKillAppliesToAnAcceptArrivingOverTheSocket() - { - var previous = LockedFilesPolicy.AlwaysKill; - // The stored preference, which Program loads into this at startup - LockedFilesPolicy.AlwaysKill = true; - try - { - await File.WriteAllTextAsync(temp, "new"); - await File.WriteAllTextAsync(target, "old"); - // The resolver Program passes - await using var tracker = new RecordingTracker(LockedFilesHandler.Resolve); - var locker = FileLockUtils.StartFileLockProcess(target); - try - { - tracker.AddMove(temp, target, "theExe", "theArguments", false, null); - - // What OwnedInlineHost does with an accept sent by a viewer displaying this queue - var (ok, message) = ((ITrackedFiles) tracker).Accept(TrackedKeys.ForMove(temp)); - - await Assert.That(message).IsNotEqualTo($"Files for '{Path.GetFileNameWithoutExtension(target)}' are locked. Accept from the tray menu to resolve."); - await Assert.That(ok).IsTrue(); - } - finally - { - FileLockUtils.Cleanup(locker); - } - - await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new"); - } - finally - { - LockedFilesPolicy.AlwaysKill = previous; - } - } } diff --git a/src/DiffEngineTray.Tests/TrackerVersionTest.cs b/src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs similarity index 89% rename from src/DiffEngineTray.Tests/TrackerVersionTest.cs rename to src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs index f9981b847..0d2675753 100644 --- a/src/DiffEngineTray.Tests/TrackerVersionTest.cs +++ b/src/DiffEngineTray.Core.Tests/TrackerVersionTest.cs @@ -14,7 +14,12 @@ public class TrackerVersionTest : IDisposable { + /// + /// Windows only: a read-only file cannot be moved over there. On Unix a rename needs only the + /// directory to be writable. + /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task AMoveTakenOutAndPutBackIsAChange() { ITrackedFiles tracked = null!; @@ -39,8 +44,13 @@ public async Task AMoveTakenOutAndPutBackIsAChange() /// /// The same gap for a delete, which is untracked while its file is deleted and tracked again /// when that fails. + /// + /// Windows only: a file open with no sharing cannot be deleted there. On Unix the lock is + /// advisory, and an unlink takes no notice of it. + /// /// [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] public async Task ADeleteTakenOutAndPutBackIsAChange() { await using var tracker = new RecordingTracker(); diff --git a/src/DiffEngineTray.Tests/TrayVersionFileTest.cs b/src/DiffEngineTray.Core.Tests/TrayVersionFileTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/TrayVersionFileTest.cs rename to src/DiffEngineTray.Core.Tests/TrayVersionFileTest.cs diff --git a/src/DiffEngineTray.Tests/VersionReaderTest.cs b/src/DiffEngineTray.Core.Tests/VersionReaderTest.cs similarity index 100% rename from src/DiffEngineTray.Tests/VersionReaderTest.cs rename to src/DiffEngineTray.Core.Tests/VersionReaderTest.cs diff --git a/src/DiffEngineTray.Core/InternalsVisibleTo.cs b/src/DiffEngineTray.Core/InternalsVisibleTo.cs index e657259a9..517cd273b 100644 --- a/src/DiffEngineTray.Core/InternalsVisibleTo.cs +++ b/src/DiffEngineTray.Core/InternalsVisibleTo.cs @@ -1,2 +1,3 @@ [assembly: InternalsVisibleTo("DiffEngineTray, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] [assembly: InternalsVisibleTo("DiffEngineTray.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] +[assembly: InternalsVisibleTo("DiffEngineTray.Core.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj index b200989ad..b9edb65fe 100644 --- a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj +++ b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj @@ -6,6 +6,15 @@ + + + + + + + + + diff --git a/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs b/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs new file mode 100644 index 000000000..ffe3ad2d7 --- /dev/null +++ b/src/DiffEngineTray.Tests/DiffToolLauncherTest.cs @@ -0,0 +1,68 @@ +/// +/// The menu's "Open diff tool", which is the head's, over a tracked move: the tracker's own tests +/// are the core's, and run on every platform. +/// +public class DiffToolLauncherTest : + IDisposable +{ + string file1 = Path.GetTempFileName(); + string file2 = Path.GetTempFileName(); + string file3 = Path.GetTempFileName(); + + public void Dispose() + { + File.Delete(file1); + File.Delete(file2); + File.Delete(file3); + } + + /// + /// DiffRunner resends a move for the same received file on every re-run, with a process + /// id each time. The update factory disposes the Process of the move it replaces and leaves it + /// there, so a menu opened before that re-run holds a move whose process is disposed, and its + /// "Open diff tool" item throws on the UI thread. + /// + [Test] + public async Task OpenDiffToolFromAMenuBuiltBeforeTheMoveWasUpdated() + { + await using var tracker = new RecordingTracker(); + var temp = file1; + var target = file2; + var toolLock = file3; + // Stands in for an auto refresh diff tool that is still open, which is what DiffRunner + // resends the id of + var tool = FileLockUtils.StartFileLockProcess(toolLock, shows: temp); + try + { + // Nothing at this path exists, so if the launcher gets past the process it starts + // nothing. Named as the tool is, since a process running something else is not tracked + var exe = Path.Combine( + Path.GetTempPath(), + $"ReviewReproNoSuchTool_{Guid.NewGuid()}", + Path.GetFileName(tool.MainModule!.FileName)); + + // What the menu captured when it opened + var shown = tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id); + await Assert.That(shown.Process).IsNotNull(); + // The re-run's move, landing while that menu is open + tracker.AddMove(temp, target, exe, "theArguments", false, tool.Id); + + Exception? thrown = null; + try + { + // The "Open diff tool" item's click handler + DiffToolLauncher.Launch(shown); + } + catch (Exception exception) + { + thrown = exception; + } + + await Assert.That(thrown).IsNull(); + } + finally + { + FileLockUtils.Cleanup(tool); + } + } +} diff --git a/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs b/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs new file mode 100644 index 000000000..0004da9f6 --- /dev/null +++ b/src/DiffEngineTray.Tests/LockedFilesHandlerTest.cs @@ -0,0 +1,77 @@ +/// +/// The tracker with the resolver the Windows head hands it, which is the head's: the tracker's +/// own tests are the core's, and run on every platform. +/// +public class LockedFilesHandlerTest : + IDisposable +{ + string tempDirectory; + string temp; + string target; + + public LockedFilesHandlerTest() + { + // The move's temp sits in its own directory, the way DiffEngine stages received files, + // because accepting a move deletes that directory. + tempDirectory = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + temp = Path.Combine(tempDirectory, "Sample.Test.received.txt"); + target = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}.verified.txt"); + } + + public void Dispose() + { + if (File.Exists(temp)) + { + File.Delete(temp); + } + + if (File.Exists(target)) + { + File.Delete(target); + } + + FileEx.SafeDeleteDirectory(tempDirectory); + } + + /// + /// With "Always kill locking processes" on, the menu accepts a locked move by killing + /// the locker without asking. The same accept arriving from the viewer is refused as locked, + /// because the preference lives inside the resolver and a wire accept never consults it. + /// + [Test] + public async Task AlwaysKillAppliesToAnAcceptArrivingOverTheSocket() + { + var previous = LockedFilesPolicy.AlwaysKill; + // The stored preference, which Program loads into this at startup + LockedFilesPolicy.AlwaysKill = true; + try + { + await File.WriteAllTextAsync(temp, "new"); + await File.WriteAllTextAsync(target, "old"); + // The resolver Program passes + await using var tracker = new RecordingTracker(LockedFilesHandler.Resolve); + var locker = FileLockUtils.StartFileLockProcess(target); + try + { + tracker.AddMove(temp, target, "theExe", "theArguments", false, null); + + // What OwnedInlineHost does with an accept sent by a viewer displaying this queue + var (ok, message) = ((ITrackedFiles) tracker).Accept(TrackedKeys.ForMove(temp)); + + await Assert.That(message).IsNotEqualTo($"Files for '{Path.GetFileNameWithoutExtension(target)}' are locked. Accept from the tray menu to resolve."); + await Assert.That(ok).IsTrue(); + } + finally + { + FileLockUtils.Cleanup(locker); + } + + await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("new"); + } + finally + { + LockedFilesPolicy.AlwaysKill = previous; + } + } +} From cc0c821c73abb1daf200d14d1d0317f1330078a0 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Oct 2026 16:56:49 +1100 Subject: [PATCH 2/2] Scrub the report's directory and its separator in one step On macOS the inline key is case folded, so the Owned test's scrubber for the lowercased directory took the directory from in front of the separator before the separator scrubber ran, and the key read {Directory}/ there. --- .../DebugReportTests.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/DiffEngineTray.Core.Tests/DebugReportTests.cs b/src/DiffEngineTray.Core.Tests/DebugReportTests.cs index 1093649bc..8c9808484 100644 --- a/src/DiffEngineTray.Core.Tests/DebugReportTests.cs +++ b/src/DiffEngineTray.Core.Tests/DebugReportTests.cs @@ -26,11 +26,7 @@ public DebugReportTests() File.WriteAllText(received, ""); settings = new(); - settings.AddScrubber(_ => _.Replace(directory, "{Directory}")); - // With the separator after it, as Windows writes one, so the report reads the same on - // every platform and one snapshot holds for all of them. Added after the one above so - // that it runs first: Verify runs the scrubber added last ahead of the others - settings.AddScrubber(_ => _.Replace(directory + Path.DirectorySeparatorChar, @"{Directory}\")); + settings.AddScrubber(_ => ScrubDirectory(_, directory)); // Read when the scrubber runs, not when it is added: every test points at an ephemeral // port so that a viewer running on this machine is not talked to, and FakeViewer moves it // again for the tests that want one @@ -120,8 +116,9 @@ public async Task Owned() host.Start(); // Ephemeral, so a viewer or tray running on this machine is not in the way settings.AddScrubber(_ => _.Replace(host.Port.ToString(), "{Port}")); - // The key a patch is filed under is case folded, so the directory shows up twice - settings.AddScrubber(_ => _.Replace(directory.ToLowerInvariant(), "{Directory}")); + // The key a patch is filed under is case folded on Windows and macOS, so the directory + // shows up twice there + settings.AddScrubber(_ => ScrubDirectory(_, directory.ToLowerInvariant())); // In lower case, so the key reads the same where it is case folded and on Linux, where it // is not @@ -142,6 +139,17 @@ public async Task Owned() await Verify(DebugReport.Build(tracker, now), settings); } + /// + /// The directory, and the separator after it as Windows writes one, so the report reads the + /// same on every platform and one snapshot holds for all of them. One scrubber for both, in + /// that order: as two, a later one ran first and took the directory from in front of the + /// separator. + /// + static void ScrubDirectory(StringBuilder builder, string path) => + builder + .Replace(path + Path.DirectorySeparatorChar, @"{Directory}\") + .Replace(path, "{Directory}"); + /// /// Owning the queue means a window can be asked for, and this test wants none. Reporting one /// as already up means none is ever started.