From cda51cac24f451988b09060301bac4ec92903bac Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 20 Jul 2026 22:16:35 +1000 Subject: [PATCH 1/2] Scope UseUniqueDirectory received paths to the runtime when multi-targeting The directory convention derives both the received and the verified paths from the verified prefix, so, unlike the file convention, every target framework shared one received path. Each verification also clears received output on init, via DeleteFiles for the shared directory and DeleteDirectory in split mode, so concurrent framework runs would delete and overwrite each others received files. Scope the received side to the runtime and version when multiple frameworks are targeted. The verified paths are left alone, so committed snapshots do not move and accepting is unaffected. Also make the native AOT test skip with the missing prerequisite named, rather than reporting a machine level toolchain gap as a wall of publish output. --- docs/mdsource/naming.source.md | 12 +++- docs/naming.md | 12 +++- src/Verify.Tests/AotCompatibilityTests.cs | 36 +++++++++++- .../Naming/UniqueDirectoryReceivedTests.cs | 58 +++++++++++++++++++ src/Verify/Verifier/InnerVerifier.cs | 27 +++++++-- 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 src/Verify.Tests/Naming/UniqueDirectoryReceivedTests.cs diff --git a/docs/mdsource/naming.source.md b/docs/mdsource/naming.source.md index f1b57df8af..21e5958b72 100644 --- a/docs/mdsource/naming.source.md +++ b/docs/mdsource/naming.source.md @@ -324,7 +324,17 @@ eg. add the following to `.gitignore` When a test project uses more than one `TargetFrameworks` (eg `net48;net7.0`) the runtime and version will always be added as a uniqueness to the received file name. This prevents file locking contention when the tests from both target framework run in parallel. -This applies to the default file naming. Under [UseUniqueDirectory](#useuniquedirectory) the received files use the same uniqueness as the verified files. +Under [UseUniqueDirectory](#useuniquedirectory) the same applies to the received side only. In split mode the received directory carries the runtime and version, and otherwise the received files inside the shared directory do: + +``` +TheTest.TheMethod/target.DotNet7_0.received.txt +TheTest.TheMethod/target.verified.txt + +TheTest.TheMethod.DotNet7_0.received/target.txt +TheTest.TheMethod.verified/target.txt +``` + +The verified names are left unscoped, so the one snapshot is shared by every target framework. ## Received and single-targeting diff --git a/docs/naming.md b/docs/naming.md index d865aef3d5..37e70bb40f 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -899,7 +899,17 @@ eg. add the following to `.gitignore` When a test project uses more than one `TargetFrameworks` (eg `net48;net7.0`) the runtime and version will always be added as a uniqueness to the received file name. This prevents file locking contention when the tests from both target framework run in parallel. -This applies to the default file naming. Under [UseUniqueDirectory](#useuniquedirectory) the received files use the same uniqueness as the verified files. +Under [UseUniqueDirectory](#useuniquedirectory) the same applies to the received side only. In split mode the received directory carries the runtime and version, and otherwise the received files inside the shared directory do: + +``` +TheTest.TheMethod/target.DotNet7_0.received.txt +TheTest.TheMethod/target.verified.txt + +TheTest.TheMethod.DotNet7_0.received/target.txt +TheTest.TheMethod.verified/target.txt +``` + +The verified names are left unscoped, so the one snapshot is shared by every target framework. ## Received and single-targeting diff --git a/src/Verify.Tests/AotCompatibilityTests.cs b/src/Verify.Tests/AotCompatibilityTests.cs index 4514ffe93f..6b77c3945e 100644 --- a/src/Verify.Tests/AotCompatibilityTests.cs +++ b/src/Verify.Tests/AotCompatibilityTests.cs @@ -55,7 +55,11 @@ public async Task Serialize_AnonymousObject_UnderAot() await File.WriteAllTextAsync(Path.Combine(tempDir, "Program.cs"), programContent); var (publishSuccess, publishOutput) = await PublishProject(tempDir); - Assert.True(publishSuccess, $"Publish failed: {publishOutput}"); + if (!publishSuccess) + { + SkipIfAotToolchainMissing(publishOutput); + Assert.Fail($"Publish failed:{Environment.NewLine}{ErrorLines(publishOutput)}"); + } var exePath = GetPublishedExePath(tempDir, "AotTestApp"); var (exitCode, stdout, _) = await RunExecutable(exePath); @@ -64,6 +68,36 @@ public async Task Serialize_AnonymousObject_UnderAot() Assert.Contains("OK", stdout); } + // A native AOT publish needs a platform linker, which on Windows comes from the "Desktop development + // with C++" workload. That is a machine prerequisite rather than anything Verify controls, so name it + // and skip, instead of reporting it as a Verify failure buried in a wall of publish output. + static void SkipIfAotToolchainMissing(string publishOutput) + { + if (publishOutput.Contains("Platform linker not found")) + { + Assert.Skip("Native AOT prerequisites are missing: the platform linker was not found. On Windows install the 'Desktop development with C++' workload. See https://aka.ms/nativeaot-prerequisites"); + } + } + + // The publish output is hundreds of lines of MSBuild noise, and the reason for the failure is the + // handful of error lines in it. + static string ErrorLines(string publishOutput) + { + var errors = publishOutput + .Split('\n') + .Select(_ => _.Trim()) + .Where(_ => _.Contains(": error ", StringComparison.Ordinal)) + .Distinct() + .ToList(); + + if (errors.Count == 0) + { + return publishOutput; + } + + return string.Join(Environment.NewLine, errors); + } + static async Task<(bool success, string output)> PublishProject(string projectDir) { var startInfo = new ProcessStartInfo diff --git a/src/Verify.Tests/Naming/UniqueDirectoryReceivedTests.cs b/src/Verify.Tests/Naming/UniqueDirectoryReceivedTests.cs new file mode 100644 index 0000000000..b2a56d40e5 --- /dev/null +++ b/src/Verify.Tests/Naming/UniqueDirectoryReceivedTests.cs @@ -0,0 +1,58 @@ +// Verify.Tests targets multiple frameworks. The directory convention derives both the received and the +// verified paths from the verified prefix, so, unlike the file convention, the received paths would +// otherwise be shared by every target framework, and the cleanup each run does on init would delete the +// received output of the others. The verified paths have to stay unscoped, so accepting is unaffected. +public class UniqueDirectoryReceivedTests +{ + [Fact] + public async Task ReceivedIsScopedToRuntimeAndVersion() + { + var directory = Path.GetFullPath(CurrentFile.Relative($"{nameof(UniqueDirectoryReceivedTests)}.{nameof(ReceivedIsScopedToRuntimeAndVersion)}")); + var scoped = Path.Combine(directory, $"target.{Namer.RuntimeAndVersion}.received.txt"); + var unscoped = Path.Combine(directory, "target.received.txt"); + try + { + await Assert.ThrowsAsync( + () => Verify("Value") + .UseUniqueDirectory() + .DisableDiff()); + Assert.True(File.Exists(scoped)); + Assert.False(File.Exists(unscoped)); + // the verified name is not scoped, so every framework shares the one snapshot + Assert.False(File.Exists(Path.Combine(directory, $"target.{Namer.RuntimeAndVersion}.verified.txt"))); + } + finally + { + File.Delete(scoped); + File.Delete(unscoped); + } + } + + [Fact] + public async Task SplitModeReceivedIsScopedToRuntimeAndVersion() + { + var prefix = Path.GetFullPath(CurrentFile.Relative($"{nameof(UniqueDirectoryReceivedTests)}.{nameof(SplitModeReceivedIsScopedToRuntimeAndVersion)}")); + var scoped = $"{prefix}.{Namer.RuntimeAndVersion}.received"; + var unscoped = $"{prefix}.received"; + try + { + await Assert.ThrowsAsync( + () => Verify("Value") + .UseUniqueDirectory() + .UseSplitModeForUniqueDirectory() + .DisableDiff()); + Assert.True(File.Exists(Path.Combine(scoped, "target.txt"))); + Assert.False(Directory.Exists(unscoped)); + // the verified directory is not scoped, so every framework shares the one snapshot directory. + // It is left in place, since the other frameworks may be asserting on it concurrently. + Assert.True(Directory.Exists($"{prefix}.verified")); + } + finally + { + IoHelpers.DeleteDirectory(scoped); + // nothing writes the unscoped directory once the received side is scoped, so this only + // clears an artifact left by a run against the unfixed behavior + IoHelpers.DeleteDirectory(unscoped); + } + } +} \ No newline at end of file diff --git a/src/Verify/Verifier/InnerVerifier.cs b/src/Verify/Verifier/InnerVerifier.cs index 0ff3d38507..51ca3a5460 100644 --- a/src/Verify/Verifier/InnerVerifier.cs +++ b/src/Verify/Verifier/InnerVerifier.cs @@ -179,10 +179,12 @@ void InitForDirectoryConvention(Namer namer, string typeAndMethod, Action new( target.Extension, - Path.Combine(directoryPrefix, $"{target.NameOrTarget}.received.{target.Extension}"), + Path.Combine(directoryPrefix, $"{target.NameOrTarget}{receivedUniqueness}.received.{target.Extension}"), Path.Combine(directoryPrefix, $"{target.NameOrTarget}.verified.{target.Extension}"), target.IsString); getIndexedFileNames = (target, index) => new( target.Extension, - Path.Combine(directoryPrefix, $"{target.NameOrTarget}#{index}.received.{target.Extension}"), + Path.Combine(directoryPrefix, $"{target.NameOrTarget}#{index}{receivedUniqueness}.received.{target.Extension}"), Path.Combine(directoryPrefix, $"{target.NameOrTarget}#{index}.verified.{target.Extension}"), target.IsString); - IoHelpers.DeleteFiles(directoryPrefix, "*.received.*"); + IoHelpers.DeleteFiles(directoryPrefix, $"*{receivedUniqueness}.received.*"); } } + // The directory convention derives both the received and the verified paths from the verified + // prefix, so, unlike the file convention, the received paths are shared by every target framework. + // When multiple frameworks are targeted, scope the received side to the runtime and version so the + // runs of each framework do not delete or overwrite each others received output. The verified paths + // are deliberately left alone, so accepting is unaffected. If the test already opted into + // UniqueForRuntimeAndVersion then the prefix separates the frameworks and nothing is needed. + static string ReceivedUniquenessForDirectoryConvention(Namer namer) + { + if (!VerifierSettings.TargetsMultipleFramework || + namer.ResolveUniqueForRuntimeAndVersion()) + { + return string.Empty; + } + + return $".{Namer.RuntimeAndVersion}"; + } + string PrefixForDirectoryConvention(Namer namer, string typeAndMethod, Action? verifiedParameters) { var uniquenessVerified = GetUniquenessVerified(PrefixUnique.SharedUniqueness(namer), namer); From 37508fb41cb523c650dead574cdf6658072c407c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 20 Jul 2026 22:18:45 +1000 Subject: [PATCH 2/2] Stop tracking the TrailingNewlinesRaw temp snapshot NewLineTests.TrailingNewlinesRaw manages this file itself, deleting it on entry, writing each variation it needs, and deleting it again at the end. The committed copy was never read, and every net10.0 run left the deletion in the working tree. Untrack it and ignore it, matching the sibling StringWithDifferingNewline file. --- .gitignore | 1 + src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt diff --git a/.gitignore b/.gitignore index a57193e23f..28bf4034f8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ src/Verify.Tests/Tests.TextNegative.verified.tmp *nCrunchTemp* *.cache src/Verify.Tests/NewLineTests.StringWithDifferingNewline.verified.txt +src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt src/Verify.MSTest.Tests/Tests.AutoVerifyHasAttachment.verified.txt src/Verify.NUnit.Tests/Tests.AutoVerifyHasAttachment.verified.txt src/Verify.TUnit.Tests/Tests.AutoVerifyHasAttachment.verified.txt diff --git a/src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt b/src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt deleted file mode 100644 index 7a2a9ee4f5..0000000000 --- a/src/Verify.Tests/NewLineTests.TrailingNewlinesRaw.verified.txt +++ /dev/null @@ -1 +0,0 @@ -a