diff --git a/docs/mdsource/naming.source.md b/docs/mdsource/naming.source.md index 21e5958b7..fb3668e23 100644 --- a/docs/mdsource/naming.source.md +++ b/docs/mdsource/naming.source.md @@ -347,6 +347,46 @@ TheTest.TheMethod.DotNet.verified.txt ``` +## Received map file + +The verified name cannot be reliably derived from the received name. A multi targeted project adds the runtime and version to the received name only, and ignored parameters are kept in the received name but dropped from the verified name. Code running inside the test has both paths available, but tooling that runs after the test run, for example a snapshot review or accept tool, only sees the files on disk. So, whenever a received file is left on disk, Verify records which verified file it belongs to. + +The record is written to the intermediate (`obj`) directory of the test project, not next to the snapshot, so it neither clutters the directory holding the code and snapshots nor is picked up by the `*.received.*` globs used to find snapshots: + +``` +{IntermediateDirectory}/VerifyReceived/{hash}.txt +``` + +Each file holds two lines, the received path and the verified path: + +``` +C:\code\MyProject\Tests\TheTest.TheMethod.DotNet11_0.received.txt +C:\code\MyProject\Tests\TheTest.TheMethod.verified.txt +``` + +The [Verify.ExceptionParsing](https://nuget.org/packages/Verify.ExceptionParsing) NuGet package provides a reader for these: + +```cs +var maps = ReceivedMaps.Read(directory); +if (maps.TryGetVerified(receivedPath, out var verifiedPath)) +{ + // accept by moving receivedPath to verifiedPath +} +``` + +`ReceivedMaps.Pairs` enumerates every pair instead, for accepting a whole run at once. + +It scans the directory recursively, so it can be pointed at a project or a repository root. `.git` and `node_modules` are skipped. + +Notes: + + * A record is only written when a received file is left on disk, so passing tests produce none, and none are written for [AutoVerify](autoverify.md). + * No records are written on a [build server](build-server.md), since nothing there consumes them, and the paths recorded do not apply off the agent. + * The file name is derived from the received path, so re running a test overwrites the same file rather than accumulating. + * Records outlive the received files they describe, for example once a snapshot has been accepted, or the test is fixed or deleted. The reader drops those, so only pairs that can be acted on are returned. They are removed from disk whenever `obj` is cleaned. + * The intermediate directory is resolved at build time via Verify's MSBuild props. A project that does not consume those props gets no records. + + ## Orphaned verified files One problem with Verify is there is currently no way to track or clean up orphaned verified files. diff --git a/docs/naming.md b/docs/naming.md index 37e70bb40..f268acd8b 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -922,6 +922,46 @@ TheTest.TheMethod.DotNet.verified.txt ``` +## Received map file + +The verified name cannot be reliably derived from the received name. A multi targeted project adds the runtime and version to the received name only, and ignored parameters are kept in the received name but dropped from the verified name. Code running inside the test has both paths available, but tooling that runs after the test run, for example a snapshot review or accept tool, only sees the files on disk. So, whenever a received file is left on disk, Verify records which verified file it belongs to. + +The record is written to the intermediate (`obj`) directory of the test project, not next to the snapshot, so it neither clutters the directory holding the code and snapshots nor is picked up by the `*.received.*` globs used to find snapshots: + +``` +{IntermediateDirectory}/VerifyReceived/{hash}.txt +``` + +Each file holds two lines, the received path and the verified path: + +``` +C:\code\MyProject\Tests\TheTest.TheMethod.DotNet11_0.received.txt +C:\code\MyProject\Tests\TheTest.TheMethod.verified.txt +``` + +The [Verify.ExceptionParsing](https://nuget.org/packages/Verify.ExceptionParsing) NuGet package provides a reader for these: + +```cs +var maps = ReceivedMaps.Read(directory); +if (maps.TryGetVerified(receivedPath, out var verifiedPath)) +{ + // accept by moving receivedPath to verifiedPath +} +``` + +`ReceivedMaps.Pairs` enumerates every pair instead, for accepting a whole run at once. + +It scans the directory recursively, so it can be pointed at a project or a repository root. `.git` and `node_modules` are skipped. + +Notes: + + * A record is only written when a received file is left on disk, so passing tests produce none, and none are written for [AutoVerify](autoverify.md). + * No records are written on a [build server](build-server.md), since nothing there consumes them, and the paths recorded do not apply off the agent. + * The file name is derived from the received path, so re running a test overwrites the same file rather than accumulating. + * Records outlive the received files they describe, for example once a snapshot has been accepted, or the test is fixed or deleted. The reader drops those, so only pairs that can be acted on are returned. They are removed from disk whenever `obj` is cleaned. + * The intermediate directory is resolved at build time via Verify's MSBuild props. A project that does not consume those props gets no records. + + ## Orphaned verified files One problem with Verify is there is currently no way to track or clean up orphaned verified files. diff --git a/src/StaticSettingsTests/ReceivedMapTests.cs b/src/StaticSettingsTests/ReceivedMapTests.cs new file mode 100644 index 000000000..7c4aefe8c --- /dev/null +++ b/src/StaticSettingsTests/ReceivedMapTests.cs @@ -0,0 +1,151 @@ +// Lives here, rather than in Verify.Tests, since these toggle the build server detection, and this +// project runs serially with BaseTest restoring that state between tests. +public class ReceivedMapTests : + BaseTest +{ + [Fact] + public async Task WritesMapToIntermediateDirectory() + { + DiffEngine.BuildServerDetector.Detected = false; + + using var temp = new TempDirectory(); + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + + await Assert.ThrowsAsync(() => Verify("value", settings)); + + var received = Received(temp); + + // The map lives in obj, not beside the snapshot, so it neither clutters the snapshot + // directory nor gets picked up by the `*.received.*` globs used to find snapshots. + Assert.Equal(received, Assert.Single(Directory.EnumerateFiles(temp))); + + Assert.Equal( + Path.Combine(temp.Path, "ReceivedMapTests.WritesMapToIntermediateDirectory.verified.txt"), + FindMap(received)); + } + + [Fact] + public async Task NoMapOnBuildServer() + { + DiffEngine.BuildServerDetector.Detected = true; + + using var temp = new TempDirectory(); + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + + await Assert.ThrowsAsync(() => Verify("value", settings)); + + // A received file is still produced, but nothing on a build server consumes a map. + Assert.NotNull(Received(temp)); + Assert.Null(FindMap(Received(temp))); + } + + [Fact] + public async Task NoMapWhenAutoVerified() + { + DiffEngine.BuildServerDetector.Detected = false; + + using var temp = new TempDirectory(); + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + settings.AutoVerify(); + + await Verify("value", settings); + + // The received file is moved to verified, so there is nothing for a map to describe. Asserted + // against the directory rather than a constructed received name, so that a name that never + // matches cannot pass this for the wrong reason. + Assert.DoesNotContain( + MapRecords(), + _ => _.Received.StartsWith(temp.Path, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task MapIsOverwrittenNotDuplicated() + { + DiffEngine.BuildServerDetector.Detected = false; + + using var temp = new TempDirectory(); + + VerifySettings Settings() + { + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + settings.DisableRequireUniquePrefix(); + return settings; + } + + await Assert.ThrowsAsync(() => Verify("value", Settings())); + var first = MapFiles().Count; + + await Assert.ThrowsAsync(() => Verify("value", Settings())); + + // The map name is derived from the received path, so a re run overwrites it rather than + // accumulating a new file each time. + Assert.Equal(first, MapFiles().Count); + } + + [Fact] + public async Task MapEnablesAccept() + { + DiffEngine.BuildServerDetector.Detected = false; + + using var temp = new TempDirectory(); + + VerifySettings Settings() + { + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + settings.DisableRequireUniquePrefix(); + return settings; + } + + await Assert.ThrowsAsync(() => Verify("value", Settings())); + + // Accept the way out of process tooling would: read the verified path from the map, rather + // than trying to derive it from the received name. + var received = Received(temp); + File.Move(received, FindMap(received)!, true); + + // The accept landed on the file Verify expects, so the next run passes. + await Verify("value", Settings()); + } + + static string MapDirectory => + Path.Combine(AttributeReader.GetIntermediateDirectory(typeof(ReceivedMapTests).Assembly), "VerifyReceived"); + + static List MapFiles() => + Directory.Exists(MapDirectory) ? Directory.EnumerateFiles(MapDirectory).ToList() : []; + + // Maps are named from a hash of the received path, so they are located by content rather than name. + static List<(string Received, string Verified)> MapRecords() + { + var records = new List<(string, string)>(); + foreach (var file in MapFiles()) + { + var lines = File.ReadAllLines(file); + if (lines.Length == 2) + { + records.Add((lines[0], lines[1])); + } + } + + return records; + } + + static string? FindMap(string receivedPath) => + MapRecords() + .Where(_ => _.Received == receivedPath) + .Select(_ => _.Verified) + .SingleOrDefault(); + + static string Received(string directory) => + Directory.EnumerateFiles(directory) + .Single(_ => _.EndsWith(".received.txt", StringComparison.Ordinal)); +} diff --git a/src/Verify.ExceptionParsing.Tests/ReceivedMapsTests.cs b/src/Verify.ExceptionParsing.Tests/ReceivedMapsTests.cs new file mode 100644 index 000000000..6bf82390b --- /dev/null +++ b/src/Verify.ExceptionParsing.Tests/ReceivedMapsTests.cs @@ -0,0 +1,153 @@ +using VerifyTests.ExceptionParsing; + +// Verify's build server detection is global state, and the contract test below toggles it. +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +public class ReceivedMapsTests +{ + // Pins the contract between the writer, in Verify, and the reader here. If either side changes + // the location or the format, this fails. + [Fact] + public async Task ReadsMapWrittenByVerify() + { + // Maps are not written on a build server, so enable the writer for this test. + var detected = DiffEngine.BuildServerDetector.Detected; + DiffEngine.BuildServerDetector.Detected = false; + try + { + using var temp = new TempDirectory(); + var settings = new VerifySettings(); + settings.UseDirectory(temp); + settings.DisableDiff(); + + await Assert.ThrowsAsync(() => VerifyXunit.Verifier.Verify("value", settings)); + + var received = Directory.EnumerateFiles(temp) + .Single(_ => _.EndsWith(".received.txt", StringComparison.Ordinal)); + + var maps = ReceivedMaps.Read(AttributeReader.GetIntermediateDirectory()); + + Assert.True(maps.TryGetVerified(received, out var verified)); + + // The received name carries the runtime and version, since this project is multi + // targeted, while the verified name does not. + Assert.Equal( + Path.Combine(temp.Path, "ReceivedMapsTests.ReadsMapWrittenByVerify.verified.txt"), + verified); + } + finally + { + DiffEngine.BuildServerDetector.Detected = detected; + } + } + + [Fact] + public void FindsMapsInNestedDirectories() + { + using var temp = new TempDirectory(); + var received = Path.Combine(temp.Path, "Foo.received.txt"); + var verified = Path.Combine(temp.Path, "Foo.verified.txt"); + File.WriteAllText(received, "value"); + WriteMap(Path.Combine(temp.Path, "project", "obj", "Debug", "net10.0"), received, verified); + + var maps = ReceivedMaps.Read(temp); + + Assert.Single(maps.Pairs); + Assert.True(maps.TryGetVerified(received, out var found)); + Assert.Equal(verified, found); + } + + [Fact] + public void ExcludesRecordWhenReceivedIsGone() + { + using var temp = new TempDirectory(); + // No received file on disk, as after the snapshot was accepted, or the test fixed or deleted. + var received = Path.Combine(temp.Path, "Foo.received.txt"); + WriteMap(Path.Combine(temp.Path, "obj"), received, Path.Combine(temp.Path, "Foo.verified.txt")); + + var maps = ReceivedMaps.Read(temp); + + // Enumerating Pairs is how a run is accepted, so a record that cannot be acted on must not + // appear there. + Assert.Empty(maps.Pairs); + Assert.False(maps.TryGetVerified(received, out _)); + } + + [Fact] + public void DeduplicatesReceivedRecordedByMoreThanOneBuild() + { + using var temp = new TempDirectory(); + var received = Path.Combine(temp.Path, "Foo.received.txt"); + var verified = Path.Combine(temp.Path, "Foo.verified.txt"); + File.WriteAllText(received, "value"); + WriteMap(Path.Combine(temp.Path, "obj", "Debug"), received, verified); + WriteMap(Path.Combine(temp.Path, "obj", "Release"), received, verified); + + var maps = ReceivedMaps.Read(temp); + + var pair = Assert.Single(maps.Pairs); + Assert.Equal(received, pair.Received); + Assert.Equal(verified, pair.Verified); + } + + [Fact] + public void SkipsDirectoriesThatCannotHoldMaps() + { + using var temp = new TempDirectory(); + var received = Path.Combine(temp.Path, "Foo.received.txt"); + File.WriteAllText(received, "value"); + WriteMap(Path.Combine(temp.Path, ".git"), received, Path.Combine(temp.Path, "Foo.verified.txt")); + + var maps = ReceivedMaps.Read(temp); + + Assert.Empty(maps.Pairs); + } + + [Fact] + public void EmptyWhenDirectoryMissing() + { + var maps = ReceivedMaps.Read(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}")); + + Assert.Empty(maps.Pairs); + Assert.False(maps.TryGetVerified("Foo.received.txt", out _)); + } + + [Fact] + public void IgnoresMalformedMaps() + { + using var temp = new TempDirectory(); + var directory = Path.Combine(temp.Path, "obj", "VerifyReceived"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "empty.txt"), ""); + File.WriteAllText(Path.Combine(directory, "single.txt"), "only-a-received-path"); + File.WriteAllLines(Path.Combine(directory, "blank.txt"), ["", ""]); + + var maps = ReceivedMaps.Read(temp); + + Assert.Empty(maps.Pairs); + } + + [Fact] + public void LookupNormalizesPaths() + { + using var temp = new TempDirectory(); + // recorded with a redundant segment, then looked up with the resolved path + var recorded = Path.Combine(temp.Path, "sub", "..", "Foo.received.txt"); + var resolved = Path.Combine(temp.Path, "Foo.received.txt"); + var verified = Path.Combine(temp.Path, "Foo.verified.txt"); + File.WriteAllText(resolved, "value"); + WriteMap(Path.Combine(temp.Path, "obj"), recorded, verified); + + var maps = ReceivedMaps.Read(temp); + + Assert.True(maps.TryGetVerified(resolved, out var found)); + Assert.Equal(verified, found); + } + + static void WriteMap(string parent, string received, string verified) + { + var directory = Path.Combine(parent, "VerifyReceived"); + Directory.CreateDirectory(directory); + File.WriteAllLines(Path.Combine(directory, "map.txt"), [received, verified]); + } +} diff --git a/src/Verify.ExceptionParsing/ReceivedMaps.cs b/src/Verify.ExceptionParsing/ReceivedMaps.cs new file mode 100644 index 000000000..00e19fa7f --- /dev/null +++ b/src/Verify.ExceptionParsing/ReceivedMaps.cs @@ -0,0 +1,203 @@ +using System.Runtime.InteropServices; + +namespace VerifyTests.ExceptionParsing; + +/// +/// Reads the received maps that Verify writes for out of process tooling. +/// +/// The verified path cannot be reliably derived from the received path. A multi targeted project adds +/// the runtime and version to the received name only, and ignored parameters are kept in the received +/// name but dropped from the verified name. So, whenever Verify leaves a received file on disk, it +/// records which verified file that received file belongs to. +/// +/// The records are written to the intermediate (obj) directory of the test project, in a +/// `VerifyReceived` directory, one file per received file, each holding the received path on the first +/// line and the verified path on the second. +/// +public sealed class ReceivedMaps +{ + // Kept in sync with the writer, ReceivedMap in Verify. + const string directoryName = "VerifyReceived"; + + static readonly StringComparer pathComparer = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + readonly Dictionary verifiedByReceived; + + ReceivedMaps(IReadOnlyList pairs, Dictionary verifiedByReceived) + { + Pairs = pairs; + this.verifiedByReceived = verifiedByReceived; + } + + /// + /// Every received and verified pair that can be acted on, one per received file. Records whose + /// received file no longer exists are excluded, as are duplicate records for the same received + /// file, so this can be enumerated directly to accept a run. + /// + public IReadOnlyList Pairs { get; } + + /// + /// Reads every received map under , recursively. Returns an empty + /// instance if the directory does not exist or contains no maps. + /// + public static ReceivedMaps Read(string directory) + { + var lookup = new Dictionary(pathComparer); + + foreach (var mapDirectory in FindMapDirectories(directory)) + { + foreach (var file in EnumerateFiles(mapDirectory)) + { + if (!TryReadPair(file, out var pair)) + { + continue; + } + + var received = Normalize(pair.Received); + + // Records outlive the received files they describe, for example once a snapshot has + // been accepted or the test has been fixed or deleted. Dropping those here keeps + // every pair returned one that can actually be acted on. + if (!File.Exists(received)) + { + continue; + } + + // The same received path can be recorded by more than one build, for example Debug + // and Release, with the same result. So the last wins rather than throwing. + lookup[received] = pair.Verified; + } + } + + // Built from the lookup, so a received file recorded more than once yields a single pair, + // and that pair agrees with what TryGetVerified returns. + var pairs = new List(lookup.Count); + foreach (var entry in lookup) + { + pairs.Add(new(entry.Key, entry.Value)); + } + + return new(pairs, lookup); + } + + /// + /// Finds the verified file that belongs to. + /// + /// + /// Records for a received file that no longer exists are never matched. + /// + public bool TryGetVerified(string receivedPath, [NotNullWhen(true)] out string? verified) => + verifiedByReceived.TryGetValue(Normalize(receivedPath), out verified); + + // A junction or symlink can make the tree cyclic, and netstandard2.0 has no way to resolve a link + // target to detect that. So the walk is bounded instead. An intermediate directory sits only a + // handful of levels below the project or repository root it is scanned from. + const int maxDepth = 20; + + static IEnumerable FindMapDirectories(string directory) + { + if (!Directory.Exists(directory)) + { + yield break; + } + + var pending = new Stack<(string Directory, int Depth)>(); + pending.Push((directory, 0)); + + while (pending.Count > 0) + { + var (current, depth) = pending.Pop(); + + string[] children; + try + { + children = Directory.GetDirectories(current); + } + catch (Exception exception) + when (exception is UnauthorizedAccessException or IOException) + { + // Skip anything that cannot be walked, rather than failing the whole scan. + continue; + } + + foreach (var child in children) + { + var name = Path.GetFileName(child); + if (string.Equals(name, directoryName, StringComparison.OrdinalIgnoreCase)) + { + // Maps are flat inside, so there is no need to descend further. + yield return child; + continue; + } + + if (depth + 1 < maxDepth && + !IsSkipped(name)) + { + pending.Push((child, depth + 1)); + } + } + } + } + + // Directories that can never hold an intermediate directory, and that are large enough to dwarf + // the rest of the walk when scanning from a repository root. + static bool IsSkipped(string name) => + string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "node_modules", StringComparison.OrdinalIgnoreCase); + + static IEnumerable EnumerateFiles(string directory) + { + try + { + return Directory.GetFiles(directory); + } + catch (Exception exception) + when (exception is UnauthorizedAccessException or IOException) + { + return []; + } + } + + static bool TryReadPair(string file, out FilePair pair) + { + string[] lines; + try + { + lines = File.ReadAllLines(file); + } + catch (Exception exception) + when (exception is UnauthorizedAccessException or IOException) + { + pair = default; + return false; + } + + // Only the first two lines are read, so that a future version adding more does not break this. + if (lines.Length < 2 || + lines[0].Length == 0 || + lines[1].Length == 0) + { + pair = default; + return false; + } + + pair = new(lines[0], lines[1]); + return true; + } + + static string Normalize(string path) + { + try + { + return Path.GetFullPath(path); + } + catch + { + // Not a usable path, so compare it as written. + return path; + } + } +} diff --git a/src/Verify/DerivePaths/AttributeReader.cs b/src/Verify/DerivePaths/AttributeReader.cs index 9ee336d7e..d89f5fe1e 100644 --- a/src/Verify/DerivePaths/AttributeReader.cs +++ b/src/Verify/DerivePaths/AttributeReader.cs @@ -38,6 +38,24 @@ public static bool TryGetProjectDirectory(Assembly assembly, [NotNullWhen(true)] public static bool TryGetProjectName(Assembly assembly, [NotNullWhen(true)] out string? projectName) => TryGetProjectName(assembly, true, out projectName); + /// + /// The intermediate (obj) directory of the project, as resolved at build time. + /// + public static string GetIntermediateDirectory() => + GetIntermediateDirectory(Assembly.GetCallingAssembly()); + + /// + public static string GetIntermediateDirectory(Assembly assembly) => + GetValue(assembly, "Verify.IntermediateDirectory"); + + /// + public static bool TryGetIntermediateDirectory([NotNullWhen(true)] out string? intermediateDirectory) => + TryGetIntermediateDirectory(Assembly.GetCallingAssembly(), out intermediateDirectory); + + /// + public static bool TryGetIntermediateDirectory(Assembly assembly, [NotNullWhen(true)] out string? intermediateDirectory) => + TryGetValue(assembly, "Verify.IntermediateDirectory", out intermediateDirectory); + public static string GetSolutionDirectory() => GetSolutionDirectory(Assembly.GetCallingAssembly()); diff --git a/src/Verify/ReceivedMap.cs b/src/Verify/ReceivedMap.cs new file mode 100644 index 000000000..8bbdde5c1 --- /dev/null +++ b/src/Verify/ReceivedMap.cs @@ -0,0 +1,65 @@ +/// +/// Records which verified file a received file belongs to. +/// +/// The verified name cannot be reliably derived from the received name. Multi targeted projects add +/// the runtime and version to the received name only, and ignored parameters are kept in the received +/// name but dropped from the verified name. In process consumers get both paths from +/// , but out of process tooling (accept/review tools) only sees files on disk. +/// So the mapping is recorded for that tooling to read. +/// +/// Maps are written to the intermediate (obj) directory rather than next to the snapshots, so that +/// they do not clutter the directory holding the code and snapshots, and so that they are not picked +/// up by the `*.received.*` globs tooling uses to find snapshots. +/// +/// The file name is derived from the received path, so a re run overwrites the same map instead of +/// accumulating. Stale maps, for example from a deleted test, are never read, since tooling looks up +/// maps by the received files that actually exist. They are removed whenever obj is cleaned. +/// +static class ReceivedMap +{ + const string directoryName = "VerifyReceived"; + + public static void Write(in FilePair file) + { + if (BuildServerDetector.Detected) + { + // Maps exist for local tooling to accept snapshots. A build server has nothing to consume + // them, and the paths recorded do not apply off the agent. + return; + } + + var intermediate = VerifierSettings.IntermediateDir; + if (intermediate is null) + { + // The project does not consume Verify's build props, so the obj directory is unknown. + return; + } + + try + { + var directory = Path.Combine(intermediate, directoryName); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, $"{Hash(file.ReceivedPath)}.txt"); + File.WriteAllText(path, $"{file.ReceivedPath}{Environment.NewLine}{file.VerifiedPath}"); + } + catch + { + // The map is an auxiliary artifact for tooling, so failing to write it must not change + // the test outcome. + } + } + + // FNV-1a. Used instead of string.GetHashCode since that is randomized per process, and the name + // has to be stable across runs for the map to be overwritten rather than duplicated. + static string Hash(string value) + { + var hash = 14695981039346656037UL; + foreach (var character in value) + { + hash ^= character; + hash *= 1099511628211UL; + } + + return hash.ToString("x16"); + } +} diff --git a/src/Verify/Verifier/VerifyEngine.cs b/src/Verify/Verifier/VerifyEngine.cs index f90cc874b..db669c696 100644 --- a/src/Verify/Verifier/VerifyEngine.cs +++ b/src/Verify/Verifier/VerifyEngine.cs @@ -266,6 +266,9 @@ async Task RunDiffAutoCheck(FilePair file, bool autoVerify) return autoVerify; } + // The received file is being left on disk, so record the verified file it belongs to. + ReceivedMap.Write(file); + if (diffEnabled) { if (file.IsText) diff --git a/src/Verify/VerifierSettings_TargetAssembly.cs b/src/Verify/VerifierSettings_TargetAssembly.cs index 53c15e69e..b1135aa90 100644 --- a/src/Verify/VerifierSettings_TargetAssembly.cs +++ b/src/Verify/VerifierSettings_TargetAssembly.cs @@ -9,6 +9,11 @@ public static partial class VerifierSettings public static string ProjectDir { get; private set; } = null!; internal static string? SolutionDir { get; private set; } + + // The obj directory, used to store the received map files. Null when the project does not consume + // Verify's build props, in which case no maps are written. + internal static string? IntermediateDir { get; private set; } + internal static bool TargetsMultipleFramework { get; private set; } = true; [Experimental("VerifierSettingsTestAssembly")] @@ -46,6 +51,8 @@ public static void AssignTargetAssembly(Assembly assembly) ProjectDir = AttributeReader.GetProjectDirectory(assembly); AttributeReader.TryGetSolutionDirectory(assembly, out var solutionDir); SolutionDir = solutionDir; + AttributeReader.TryGetIntermediateDirectory(assembly, out var intermediateDir); + IntermediateDir = intermediateDir; if (AttributeReader.TryGetTargetFrameworks(assembly, out var targetFrameworks)) { TargetsMultipleFramework = targetFrameworks.Contains(';'); diff --git a/src/Verify/buildTransitive/Verify.props b/src/Verify/buildTransitive/Verify.props index 2137668b3..d2a0e208d 100644 --- a/src/Verify/buildTransitive/Verify.props +++ b/src/Verify/buildTransitive/Verify.props @@ -98,12 +98,19 @@ Outputs="$(IntermediateOutputPath)$(VerifyAttributesFile)"> $(IntermediateOutputPath)$(VerifyAttributesFile) + + $([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)')) <_Parameter1>Verify.TargetFrameworks <_Parameter2>$(TargetFrameworks) + + <_Parameter1>Verify.IntermediateDirectory + <_Parameter2>$(VerifyIntermediateDirectory) + <_Parameter1>Verify.ProjectDirectory <_Parameter2>$(ProjectDir)