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; + } + } +}