Skip to content

Commit 85bc04e

Browse files
committed
Persist pending inline snapshots when the queue owner exits
The queue lives in the owner's memory, so an owning viewer that was closed (with no tray running), or a tray that exited, silently discarded every pending inline snapshot. Unlike a file snapshot, whose received file stays on disk when its diff tool closes, an inline snapshot had nothing left anywhere; the only recovery was re-running the tests, with nothing saying so. InlineStaging writes what is still pending back to disk on the way out, in the staging layout Verify uses when no owner answers a run: the patch and its two texts under a VerifyInline directory in the source project's obj. Accept tooling already reads that layout, so a closed owner now degrades to the no-owner arrangement instead of to nothing. One trio per variant, so a conflicted entry keeps each framework's content; deterministic names, so re-persisting overwrites; a patch whose source file is gone is skipped, since nothing could ever apply it. Both hosts persist: the viewer on every owner exit path after its listener stops, and the tray in OwnedInlineHost.DisposeAsync. A kill or a crash still loses the queue. Also route Q, Escape and the Close menu item through the same close semantics as the window's close button. Quit-as-exit skipped the tray check, so the keyboard threw away an owning viewer's queue even in the arrangement where the close button hid the window and kept it - and on a no-tray machine it now persists before exiting, like every other exit.
1 parent 42418fd commit 85bc04e

13 files changed

Lines changed: 570 additions & 10 deletions

File tree

docs/mdsource/tray.source.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ When the tray holds it, the window becomes disposable. A viewer that is closed,
5252

5353
"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way.
5454

55-
A tray restart loses the queue, as it loses pending moves and deletes. Re-run the tests.
55+
Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests.
5656

5757

5858
### Accept all

docs/mdsource/viewer.source.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ The point of that arrangement is that the queue outlives the window. A viewer th
157157

158158
If a viewer was already running when the tray started, the viewer keeps the queue for as long as it lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either way both surfaces run the same queue implementation, so they cannot disagree on what accepting or settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on a particular snapshot, and close the viewer.
159159

160-
A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests.
160+
Exiting the queue's owner — closing an owning viewer with no tray running, or exiting the tray — writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending file moves and deletes; re-run the tests.
161161

162162

163163
## With no tray

docs/tray.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ When the tray holds it, the window becomes disposable. A viewer that is closed,
5959

6060
"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way.
6161

62-
A tray restart loses the queue, as it loses pending moves and deletes. Re-run the tests.
62+
Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests.
6363

6464

6565
### Accept all

docs/viewer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ The point of that arrangement is that the queue outlives the window. A viewer th
164164

165165
If a viewer was already running when the tray started, the viewer keeps the queue for as long as it lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either way both surfaces run the same queue implementation, so they cannot disagree on what accepting or settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on a particular snapshot, and close the viewer.
166166

167-
A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests.
167+
Exiting the queue's owner — closing an owning viewer with no tray running, or exiting the tray — writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending file moves and deletes; re-run the tests.
168168

169169

170170
## With no tray
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/// <summary>
2+
/// Persisting a queue back to disk is what stands between "the owner exited" and "every pending
3+
/// snapshot silently gone", so these pin the layout accept tooling reads: the file trio, where it
4+
/// lands relative to the source's project, and the naming that carries the framework label.
5+
/// </summary>
6+
public class InlineStagingTests
7+
{
8+
[Test]
9+
public async Task WritesTheTrioUnderTheProjectsObj()
10+
{
11+
using var project = new TempProject();
12+
var source = project.Source("SampleTests.cs");
13+
14+
var patch = Patch(source, "line one\nline two", framework: "net10.0");
15+
var written = InlineStaging.Persist([new PendingInline(patch)]);
16+
17+
await Assert.That(written).IsEqualTo(1);
18+
19+
var files = project.StagedFiles();
20+
await Assert.That(files.Count).IsEqualTo(3);
21+
22+
var patchFile = files.Single(_ => _.EndsWith(".inlinepatch"));
23+
// The framework rides the name's last dot segment, dots folded to underscores so the
24+
// label survives being read back off the file name.
25+
await Assert.That(Path.GetFileName(patchFile))
26+
.IsEqualTo($"SampleTests.Sample.{Hash(source)}.net10_0.inlinepatch");
27+
28+
await Assert.That(InlinePatchFile.TryRead(patchFile, out var read)).IsTrue();
29+
await Assert.That(read!.SourceFile).IsEqualTo(patch.SourceFile);
30+
await Assert.That(read.LineHint).IsEqualTo(patch.LineHint);
31+
await Assert.That(read.NewContent).IsEqualTo(patch.NewContent);
32+
await Assert.That(read.OriginalValue).IsEqualTo(patch.OriginalValue);
33+
await Assert.That(read.Framework).IsEqualTo("net10.0");
34+
35+
await Assert.That(File.ReadAllText(files.Single(_ => _.EndsWith(".received.txt"))))
36+
.IsEqualTo("line one\nline two");
37+
await Assert.That(File.ReadAllText(files.Single(_ => _.EndsWith(".expected.txt"))))
38+
.IsEqualTo("old");
39+
}
40+
41+
[Test]
42+
public async Task PersistingAgainOverwritesRatherThanAccumulates()
43+
{
44+
using var project = new TempProject();
45+
var source = project.Source("SampleTests.cs");
46+
47+
InlineStaging.Persist([new PendingInline(Patch(source, "first", framework: "net10.0"))]);
48+
InlineStaging.Persist([new PendingInline(Patch(source, "second", framework: "net10.0"))]);
49+
50+
var files = project.StagedFiles();
51+
await Assert.That(files.Count).IsEqualTo(3);
52+
await Assert.That(File.ReadAllText(files.Single(_ => _.EndsWith(".received.txt"))))
53+
.IsEqualTo("second");
54+
}
55+
56+
[Test]
57+
public async Task ConflictedEntryKeepsEachFrameworksContent()
58+
{
59+
using var project = new TempProject();
60+
var source = project.Source("SampleTests.cs");
61+
62+
var entry = new PendingInline(
63+
[
64+
new(Patch(source, "from net8", framework: "net8.0"), ["net8.0"]),
65+
new(Patch(source, "from net10", framework: "net10.0"), ["net10.0"]),
66+
]);
67+
68+
var written = InlineStaging.Persist([entry]);
69+
70+
// One trio per variant, distinct by the framework segment, so a reader regrouping by call
71+
// site sees the disagreement instead of one framework's content standing for both.
72+
await Assert.That(written).IsEqualTo(2);
73+
var names = project.StagedFiles().Select(Path.GetFileName).ToList();
74+
await Assert.That(names.Count(_ => _!.Contains(".net8_0."))).IsEqualTo(3);
75+
await Assert.That(names.Count(_ => _!.Contains(".net10_0."))).IsEqualTo(3);
76+
}
77+
78+
[Test]
79+
public async Task SourceWithNoProjectAboveItIsSkipped()
80+
{
81+
// A path from another machine, or a project deleted since the run: nowhere honest to
82+
// stage, and skipped is better than a guess.
83+
var source = Path.Combine(Path.GetTempPath(), $"inline-staging-none-{Guid.NewGuid():N}", "SampleTests.cs");
84+
85+
var written = InlineStaging.Persist([new PendingInline(Patch(source, "content"))]);
86+
87+
await Assert.That(written).IsEqualTo(0);
88+
}
89+
90+
[Test]
91+
public async Task RemoveIsNeverPersisted()
92+
{
93+
using var project = new TempProject();
94+
var source = project.Source("SampleTests.cs");
95+
96+
var remove = new InlinePatch(source, 42, "\"old\"", "", InlinePatchMode.Remove)
97+
{
98+
TestName = null,
99+
OriginalValue = "old"
100+
};
101+
102+
var written = InlineStaging.Persist([new PendingInline(remove)]);
103+
104+
await Assert.That(written).IsEqualTo(0);
105+
await Assert.That(project.StagedFiles()).IsEmpty();
106+
}
107+
108+
[Test]
109+
public async Task UnlabeledPatchStillPersists()
110+
{
111+
using var project = new TempProject();
112+
var source = project.Source("SampleTests.cs");
113+
114+
var written = InlineStaging.Persist([new PendingInline(Patch(source, "content"))]);
115+
116+
await Assert.That(written).IsEqualTo(1);
117+
var patchFile = project.StagedFiles().Single(_ => _.EndsWith(".inlinepatch"));
118+
await Assert.That(Path.GetFileName(patchFile)).EndsWith(".unknown.inlinepatch");
119+
}
120+
121+
static InlinePatch Patch(string source, string content, string? framework = null) =>
122+
new(source, 42, "\"old\"", content)
123+
{
124+
TestName = "SampleTests.Sample",
125+
OriginalValue = "old",
126+
Framework = framework
127+
};
128+
129+
// The name embeds an fnv1a of the call site so re-persisting overwrites; recomputed here so
130+
// the expected file name can be asserted exactly.
131+
static string Hash(string source)
132+
{
133+
var hash = 2166136261u;
134+
foreach (var character in $"{source}:42")
135+
{
136+
hash = (hash ^ character) * 16777619u;
137+
}
138+
139+
return hash.ToString("x8");
140+
}
141+
142+
// A directory shaped like a project: a project file at the top, a source file beside it, and
143+
// obj/VerifyInline expected to appear under it.
144+
sealed class TempProject : IDisposable
145+
{
146+
readonly string directory = Path.Combine(
147+
Path.GetTempPath(),
148+
$"inline-staging-{Guid.NewGuid():N}");
149+
150+
public TempProject()
151+
{
152+
Directory.CreateDirectory(directory);
153+
File.WriteAllText(Path.Combine(directory, "Sample.csproj"), "<Project />");
154+
}
155+
156+
public string Source(string name)
157+
{
158+
var path = Path.Combine(directory, name);
159+
File.WriteAllText(path, "// sample");
160+
return path;
161+
}
162+
163+
public IReadOnlyList<string> StagedFiles()
164+
{
165+
var staging = Path.Combine(directory, "obj", InlineStaging.DirectoryName);
166+
return Directory.Exists(staging)
167+
? Directory.GetFiles(staging).OrderBy(_ => _, StringComparer.Ordinal).ToList()
168+
: [];
169+
}
170+
171+
public void Dispose()
172+
{
173+
try
174+
{
175+
Directory.Delete(directory, recursive: true);
176+
}
177+
catch
178+
{
179+
// Best effort cleanup of the temp directory.
180+
}
181+
}
182+
}
183+
}

0 commit comments

Comments
 (0)