Skip to content

Commit 39c8344

Browse files
committed
Refactor Program and tests for improved resource management
- Implement IDisposable in Program.cs for better resource handling. - Add CancellationTokenSource for managing long-running tasks. - Update process handling with using statements and error logging. - Enhance BlakeServeCommandTests with increased timeouts and exception assertions. - Refactor RunProcessAsync in TestFixtureBase for clarity and improved cancellation handling. - Update ProcessResult to include a nullable Canceled property.
1 parent 1a40fa6 commit 39c8344

File tree

3 files changed

+128
-54
lines changed

3 files changed

+128
-54
lines changed

src/Blake.CLI/Program.cs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
namespace Blake.CLI;
88

9-
public class Program
9+
public class Program : IDisposable
1010
{
11+
private static CancellationTokenSource? _cancellationTokenSource;
12+
1113
public static async Task<int> Main(string[] args)
1214
{
1315
using var loggerFactory = CreateLoggerFactory(args);
@@ -230,16 +232,44 @@ private static async Task<int> ServeBakeAsync(string[] args, ILogger logger)
230232
UseShellExecute = true
231233
};
232234

233-
var process = Process.Start(psi);
235+
using var process = Process.Start(psi);
236+
234237
if (process == null)
235238
{
236-
Console.WriteLine("❌ Failed to start the process. Please check the configuration and try again.");
239+
logger.LogError("Failed to start the process. Please check the configuration and try again.");
237240
return 1;
238241
}
239-
await process.WaitForExitAsync();
242+
243+
_cancellationTokenSource = new CancellationTokenSource();
244+
245+
var waitTask = process.WaitForExitAsync();
246+
var cancelTask = Task.Delay(Timeout.InfiniteTimeSpan, _cancellationTokenSource.Token);
247+
248+
var completed = await Task.WhenAny(waitTask, cancelTask);
249+
if (completed != waitTask)
250+
{
251+
TryGraceful(process);
252+
if (!process.HasExited)
253+
process.Kill(entireProcessTree: true);
254+
255+
// Catch if the process was killed or exited unexpectedly
256+
try { await waitTask; } catch { /* ignore */ }
257+
}
258+
240259
return 0;
241260
}
242261

262+
static void TryGraceful(Process proc)
263+
{
264+
// Optional gentle close on Windows if there is a windowed host
265+
if (OperatingSystem.IsWindows())
266+
{
267+
try { if (proc.CloseMainWindow()) return; } catch { /* ignore */ }
268+
}
269+
// On Unix you could send SIGTERM if you manage process groups.
270+
}
271+
272+
243273
private static async Task<int> NewSiteAsync(string[] args, ILogger logger)
244274
{
245275
Console.WriteLine();
@@ -409,4 +439,11 @@ private static string GetPathFromArgs(string[] args)
409439

410440
return Directory.GetCurrentDirectory();
411441
}
442+
443+
public void Dispose()
444+
{
445+
_cancellationTokenSource?.Cancel();
446+
_cancellationTokenSource?.Dispose();
447+
GC.SuppressFinalize(this);
448+
}
412449
}

tests/Blake.IntegrationTests/Commands/BlakeServeCommandTests.cs

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Blake.IntegrationTests.Infrastructure;
2+
using System.ComponentModel;
23

34
namespace Blake.IntegrationTests.Commands;
45

@@ -17,11 +18,10 @@ public async Task BlakeServe_WithNonExistentPath_ShowsError()
1718

1819
// Act - Use a short timeout since serve command runs indefinitely
1920
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
20-
var result = await RunBlakeFromDotnetAsync("serve", nonExistentPath, cancellationToken: cts.Token);
21+
var action = RunBlakeFromDotnetAsync("serve", nonExistentPath, cancellationToken: cts.Token);
2122

2223
// Assert
23-
Assert.NotEqual(0, result.ExitCode);
24-
Assert.Contains("does not exist", result.ErrorText);
24+
await Assert.ThrowsAsync<Win32Exception>(async () => await action);
2525
}
2626

2727
[Fact]
@@ -44,8 +44,8 @@ public async Task BlakeServe_BakesBeforeServing()
4444
<div>@Body</div>"
4545
);
4646

47-
// Act - Start serve command but cancel quickly
48-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
47+
// Act - Start serve command
48+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
4949
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
5050

5151
// Assert
@@ -78,7 +78,7 @@ public async Task BlakeServe_WithBakeFailure_DoesNotStartServer()
7878
// Intentionally don't create template to potentially cause failure
7979

8080
// Act
81-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
81+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
8282
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
8383

8484
// Assert
@@ -98,7 +98,7 @@ public async Task BlakeServe_WithValidProject_AttemptsToRunDotnetRun()
9898
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "ValidServe");
9999

100100
// Act
101-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
101+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
102102
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
103103

104104
// Assert
@@ -117,13 +117,29 @@ public async Task BlakeServe_PassesThroughOptions()
117117
// Arrange
118118
var testDir = CreateTempDirectory("blake-serve-options");
119119
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "OptionsTest");
120+
121+
var content = @"
122+
This is a test post with a default container. The resulting Razor should not include Bootstrap styles.
123+
124+
:::tip
125+
This is a tip block that should not be styled with Bootstrap.
126+
:::
127+
";
120128

121129
FileSystemHelper.CreateMarkdownFile(
122130
Path.Combine(testDir, "Posts", "post.md"),
123131
"Test Post",
124-
"Content"
132+
content
125133
);
126134

135+
// Create a Razor template that does not use Bootstrap styles
136+
FileSystemHelper.CreateRazorTemplate(
137+
Path.Combine(testDir, "Components", "TipContainer.razor"),
138+
@"<div>@ChildContent</div>
139+
@code {
140+
[Parameter] public RenderFragment? ChildContent { get; set;
141+
}");
142+
127143
FileSystemHelper.CreateRazorTemplate(
128144
Path.Combine(testDir, "Posts", "template.razor"),
129145
@"@page ""/posts/{Slug}""
@@ -136,11 +152,14 @@ public async Task BlakeServe_PassesThroughOptions()
136152
var result = await RunBlakeCommandAsync(["serve", testDir, "--disableDefaultRenderers"], cts.Token);
137153

138154
// Assert
139-
// Should have passed through the option to the bake step
140-
Assert.Contains(result.OutputText, o => o.Contains("Baking in:"));
141-
142-
// Should have created generated content despite the option
155+
// Should have created generated content despite the option//
143156
FileSystemHelper.AssertDirectoryExists(Path.Combine(testDir, ".generated"));
157+
// Generated Razor file should not include Bootstrap styles
158+
var generatedFile = Path.Combine(testDir, ".generated", "posts", "Post.razor");
159+
Assert.True(File.Exists(generatedFile), "Generated Razor file should exist after serving with options.");
160+
var generatedContent = await File.ReadAllTextAsync(generatedFile);
161+
Assert.Contains("<TipContainer", generatedContent);
162+
Assert.DoesNotContain("alert-secondary", generatedContent);
144163
}
145164

146165
[Fact]
@@ -149,16 +168,16 @@ public async Task BlakeServe_WithMissingContentFolder_HandlesGracefully()
149168
// Arrange
150169
var testDir = CreateTempDirectory("blake-serve-no-content");
151170
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "NoContent");
152-
171+
153172
// Don't create any Posts or Pages folders
154173

155174
// Act
156-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
175+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
157176
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
158177

159178
// Assert
160179
// Should handle missing content gracefully and still try to serve
161-
Assert.True(result.ExitCode == 0 || result.ErrorText.Contains("was canceled"));
180+
Assert.True((result.Canceled.HasValue && result.Canceled.Value == true) || result.ExitCode == 0);
162181

163182
// Should still attempt baking
164183
Assert.Contains(result.OutputText, o => o.Contains("Baking in:"));
@@ -172,10 +191,13 @@ public async Task BlakeServe_CreatesGeneratedFolder()
172191
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "CreateFolder");
173192

174193
// Act
175-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
194+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
176195
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
177196

178197
// Assert
198+
// Should be cancelled
199+
Assert.True((result.Canceled.HasValue && result.Canceled.Value == true) || result.ExitCode == 0);
200+
179201
// Should create .generated folder as part of the bake step
180202
FileSystemHelper.AssertDirectoryExists(Path.Combine(testDir, ".generated"));
181203
}
@@ -188,11 +210,11 @@ public async Task BlakeServe_UsesCurrentDirectoryWhenNoPathProvided()
188210
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "CurrentDir");
189211

190212
// Act - Run blake serve without path argument from the project directory
191-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
213+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
192214
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
193215

194216
// Assert
195-
Assert.True(result.ExitCode == 0 || result.ErrorText.Contains("was canceled"));
217+
Assert.True((result.Canceled.HasValue && result.Canceled.Value == true) || result.ExitCode == 0);
196218
Assert.Contains(result.OutputText, o => o.Contains("Baking in:"));
197219

198220
// Should create .generated in the working directory
@@ -207,7 +229,7 @@ public async Task BlakeServe_ShowsProgressMessages()
207229
await FileSystemHelper.CreateBlazorWasmProjectAsync(testDir, "Progress");
208230

209231
// Act
210-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
232+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
211233
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
212234

213235
// Assert
@@ -245,7 +267,7 @@ public async Task BlakeServe_IntegrationWithBakeOptions()
245267
);
246268

247269
// Act - Should not include drafts by default
248-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
270+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
249271
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
250272

251273
// Assert
@@ -264,7 +286,7 @@ public async Task BlakeServe_HandlesProjectWithoutCsproj()
264286
// Just create a directory without a proper Blazor project
265287

266288
// Act
267-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
289+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
268290
var result = await RunBlakeFromDotnetAsync("serve", testDir, cancellationToken: cts.Token);
269291

270292
// Assert

tests/Blake.IntegrationTests/Infrastructure/TestFixtureBase.cs

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -69,50 +69,64 @@ protected async Task<ProcessResult> RunBlakeFromDotnetAsync(string command, stri
6969
/// <summary>
7070
/// Runs an arbitrary process and captures the result.
7171
/// </summary>
72-
protected async Task<ProcessResult> RunProcessAsync(string fileName, string arguments, string workingDirectory = "", CancellationToken cancellationToken = default)
72+
protected async Task<ProcessResult> RunProcessAsync(
73+
string fileName,
74+
string arguments,
75+
string workingDirectory = "",
76+
CancellationToken cancellationToken = default)
7377
{
74-
using var process = new Process();
75-
process.StartInfo = new ProcessStartInfo
78+
using var process = new Process
7679
{
77-
FileName = fileName,
78-
Arguments = arguments,
79-
UseShellExecute = false,
80-
RedirectStandardOutput = true,
81-
RedirectStandardError = true,
82-
CreateNoWindow = true,
83-
WorkingDirectory = string.IsNullOrEmpty(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory
80+
StartInfo = new ProcessStartInfo
81+
{
82+
FileName = fileName,
83+
Arguments = arguments,
84+
UseShellExecute = false,
85+
RedirectStandardOutput = true,
86+
RedirectStandardError = true,
87+
CreateNoWindow = true,
88+
WorkingDirectory = string.IsNullOrEmpty(workingDirectory)
89+
? Directory.GetCurrentDirectory()
90+
: workingDirectory
91+
},
92+
EnableRaisingEvents = true
8493
};
8594

86-
var outputBuilder = new List<string>();
87-
var errorBuilder = new List<string>();
88-
89-
process.OutputDataReceived += (_, e) =>
90-
{
91-
if (e.Data != null)
92-
outputBuilder.Add(e.Data);
93-
};
95+
var output = new List<string>();
96+
var error = new List<string>();
9497

95-
process.ErrorDataReceived += (_, e) =>
96-
{
97-
if (e.Data != null)
98-
errorBuilder.Add(e.Data);
99-
};
98+
process.OutputDataReceived += (_, e) => { if (e.Data != null) output.Add(e.Data); };
99+
process.ErrorDataReceived += (_, e) => { if (e.Data != null) error.Add(e.Data); };
100100

101101
var startTime = DateTime.UtcNow;
102102
process.Start();
103103
process.BeginOutputReadLine();
104104
process.BeginErrorReadLine();
105105

106-
await process.WaitForExitAsync(cancellationToken);
106+
var waitTask = process.WaitForExitAsync();
107+
var cancelTask = Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
108+
109+
var completed = await Task.WhenAny(waitTask, cancelTask);
110+
if (completed != waitTask)
111+
{
112+
// Test timeout/cancel: terminate child and still return a result
113+
try { process.CloseMainWindow(); } catch { /* ignore */ }
114+
if (!process.HasExited)
115+
process.Kill(entireProcessTree: true);
116+
117+
try { await waitTask; } catch { /* ignore */ }
118+
}
107119

108120
return new ProcessResult(
109-
ExitCode: process.ExitCode,
110-
Output: outputBuilder,
111-
Error: errorBuilder,
112-
Duration: DateTime.UtcNow - startTime
121+
ExitCode: process.HasExited ? process.ExitCode : 0, // process may not terminate immediately
122+
Output: output,
123+
Error: error,
124+
Duration: DateTime.UtcNow - startTime,
125+
Canceled: completed != waitTask ? (bool?)true : null
113126
);
114127
}
115128

129+
116130
/// <summary>
117131
/// Gets the path to the Blake CLI project for testing.
118132
/// </summary>
@@ -165,7 +179,8 @@ public record ProcessResult(
165179
int ExitCode,
166180
IReadOnlyList<string> Output,
167181
IReadOnlyList<string> Error,
168-
TimeSpan Duration
182+
TimeSpan Duration,
183+
bool? Canceled = null
169184
)
170185
{
171186
public List<string> OutputText => [.. Output];

0 commit comments

Comments
 (0)