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/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.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
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);