From 177474bc5c1d9e8a2c0595b8c75bad7f706bf15d Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 23 May 2026 17:45:56 +0800 Subject: [PATCH 1/3] test(Bowl): add end-to-end crash pipeline integration tests - BowlCrashPipelineTests: simulates full crash -> dump -> report -> callback flow - MockCrashStrategy writes fake dump file, real CrashReporter creates JSON - Verifies DumpCaptured, file existence, report content, OnCrash callback data - Tests Normal mode (no restore) and healthy exit (no crash) scenarios - Added InternalsVisibleTo to Bowl.csproj for test access to internal DI constructor - Outputs all file paths and report contents via ITestOutputHelper for inspection --- .../Integration/BowlCrashPipelineTests.cs | 339 ++++++++++++++++++ .../GeneralUpdate.Bowl.csproj | 3 + 2 files changed, 342 insertions(+) create mode 100644 src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs diff --git a/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs b/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs new file mode 100644 index 00000000..f0a6156f --- /dev/null +++ b/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Bowl; +using GeneralUpdate.Bowl.Internal; +using GeneralUpdate.Bowl.Strategies; +using Xunit.Abstractions; + +namespace BowlTest.Integration +{ + /// + /// End-to-end crash simulation tests that produce real output files. + /// Uses mock strategies to simulate procdump output so tests run + /// on any machine without requiring procdump binaries. + /// + public class BowlCrashPipelineTests : IDisposable + { + private readonly string _testBasePath; + private readonly ITestOutputHelper _output; + + public BowlCrashPipelineTests(ITestOutputHelper output) + { + _output = output; + _testBasePath = Path.Combine(Path.GetTempPath(), $"BowlCrashTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testBasePath); + } + + public void Dispose() + { + if (Directory.Exists(_testBasePath)) + { + try { Directory.Delete(_testBasePath, recursive: true); } + catch { /* best effort */ } + } + } + + /// + /// Simulates a full crash → dump → report → callback pipeline. + /// Produces real files that can be inspected on disk. + /// + [Fact] + public async Task SimulatedCrash_ProducesDumpAndReportFiles() + { + // ---- Arrange ---- + var installPath = Path.Combine(_testBasePath, "MyApp"); + var backupDir = Path.Combine(installPath, "1.0.0"); + var failDir = Path.Combine(installPath, "fail", "2.0.0"); + var expectedDumpFile = "2.0.0_fail.dmp"; + var expectedReportFile = "2.0.0_fail.json"; + var expectedDumpPath = Path.Combine(failDir, expectedDumpFile); + var expectedReportPath = Path.Combine(failDir, expectedReportFile); + + // Create backup directory with some fake backup files + Directory.CreateDirectory(backupDir); + File.WriteAllText(Path.Combine(backupDir, "MyApp.dll"), "v1.0.0 backup"); + + // The mock strategy will simulate procdump writing a dump file + var mockStrategy = new MockCrashStrategy(expectedDumpPath, new[] + { + "[10:00:01] Waiting for process 'MyApp.exe' to start...", + "[10:00:02] Process started. Attaching...", + "[10:00:02] Dump count reached. Writing dump file...", + "[10:00:03] Dump written.", + "[10:00:03] Dump count reached.", + "[10:00:03] Dump 1 initiated: " + expectedDumpPath, + }); + + CrashInfo? capturedCrash = null; + var callbackFired = new TaskCompletionSource(); + + var context = new BowlContext + { + ProcessNameOrId = "MyApp.exe", + DumpFileName = expectedDumpFile, + FailFileName = expectedReportFile, + TargetPath = installPath, + FailDirectory = failDir, + BackupDirectory = backupDir, + WorkModel = "Upgrade", + ExtendedField = "2.0.0", + TimeoutMs = 5_000, + DumpType = DumpType.Full, + AutoRestore = true, + OnCrash = (info, ct) => + { + capturedCrash = info; + callbackFired.TrySetResult(true); + return Task.CompletedTask; + }, + }; + + var bowl = CreateBowl(mockStrategy); + + _output.WriteLine("=== Test Setup ==="); + _output.WriteLine($"Install: {installPath}"); + _output.WriteLine($"Backup: {backupDir}"); + _output.WriteLine($"Fail: {failDir}"); + _output.WriteLine($"Dump: {expectedDumpPath}"); + _output.WriteLine($"Report: {expectedReportPath}"); + _output.WriteLine(""); + + // ---- Act ---- + _output.WriteLine("=== Launching Bowl ==="); + BowlResult result; + try + { + result = await bowl.LaunchAsync(context, CancellationToken.None); + } + catch (Exception ex) + { + _output.WriteLine($"Exception: {ex.GetType().Name}: {ex.Message}"); + throw; + } + + _output.WriteLine(""); + _output.WriteLine("=== Results ==="); + _output.WriteLine($"Success: {result.Success}"); + _output.WriteLine($"ExitCode: {result.ExitCode}"); + _output.WriteLine($"DumpCaptured: {result.DumpCaptured}"); + _output.WriteLine($"DumpFilePath: {result.DumpFilePath}"); + _output.WriteLine($"CrashReport: {result.CrashReportPath}"); + _output.WriteLine($"Restored: {result.Restored}"); + + // ---- Assert ---- + + // 1. Crash was detected + Assert.False(result.Success); + Assert.True(result.DumpCaptured, "Dump should be captured"); + Assert.True(result.Restored, "Backup should be restored in Upgrade mode"); + + // 2. Dump file exists + Assert.True(File.Exists(expectedDumpPath), + $"Dump file should exist at {expectedDumpPath}"); + + // 3. Crash report exists and is valid JSON + Assert.True(File.Exists(expectedReportPath), + $"Crash report should exist at {expectedReportPath}"); + + var reportContent = File.ReadAllText(expectedReportPath); + Assert.NotEmpty(reportContent); + Assert.Contains("2.0.0", reportContent); + Assert.Contains("MyApp.exe", reportContent); + Assert.Contains("ProcdumpOutPutLines", reportContent); + + _output.WriteLine(""); + _output.WriteLine("=== Crash Report Content ==="); + _output.WriteLine(reportContent); + + // 4. OnCrash callback was fired with correct data + Assert.True(callbackFired.Task.IsCompleted, "OnCrash callback should have fired"); + Assert.NotNull(capturedCrash); + Assert.Equal(expectedDumpPath, capturedCrash!.Value.DumpFilePath); + Assert.Equal(expectedReportPath, capturedCrash.Value.CrashReportPath); + Assert.Equal("2.0.0", capturedCrash.Value.Version); + + _output.WriteLine(""); + _output.WriteLine("=== Callback Data ==="); + _output.WriteLine($" Dump: {capturedCrash.Value.DumpFilePath}"); + _output.WriteLine($" Report: {capturedCrash.Value.CrashReportPath}"); + _output.WriteLine($" Version: {capturedCrash.Value.Version}"); + _output.WriteLine($" ExitCode: {capturedCrash.Value.ExitCode}"); + + // 5. Fail directory contains all expected files + var failFiles = Directory.GetFiles(failDir); + _output.WriteLine(""); + _output.WriteLine("=== Files in fail directory ==="); + foreach (var f in failFiles) + _output.WriteLine($" {f} ({new FileInfo(f).Length} bytes)"); + + Assert.Contains(failFiles, f => f.EndsWith(".dmp")); + Assert.Contains(failFiles, f => f.EndsWith(".json")); + } + + /// + /// Simulates a crash in Normal mode (no restore, no env var). + /// + [Fact] + public async Task SimulatedCrash_NormalMode_DoesNotRestore() + { + var installPath = Path.Combine(_testBasePath, "NormalModeApp"); + var failDir = Path.Combine(installPath, "fail", "3.0.0"); + var dumpPath = Path.Combine(failDir, "3.0.0_fail.dmp"); + + Directory.CreateDirectory(Path.Combine(installPath, "3.0.0")); + File.WriteAllText(Path.Combine(installPath, "3.0.0", "some.dll"), "backup"); + + var mockStrategy = new MockCrashStrategy(dumpPath, new[] { "Dump written." }); + + var context = new BowlContext + { + ProcessNameOrId = "NormalApp.exe", + DumpFileName = "3.0.0_fail.dmp", + FailFileName = "3.0.0_fail.json", + TargetPath = installPath, + FailDirectory = failDir, + BackupDirectory = Path.Combine(installPath, "3.0.0"), + WorkModel = "Normal", + ExtendedField = "3.0.0", + TimeoutMs = 5_000, + AutoRestore = true, + }; + + var bowl = CreateBowl(mockStrategy); + var result = await bowl.LaunchAsync(context); + + _output.WriteLine($"Normal mode — Restored: {result.Restored}, DumpCaptured: {result.DumpCaptured}"); + + Assert.True(result.DumpCaptured); + Assert.False(result.Restored, "Normal mode should NOT restore backup"); + Assert.True(File.Exists(dumpPath)); + } + + /// + /// Verifies that when no dump is produced, the pipeline returns success + /// and does NOT create a crash report. + /// + [Fact] + public async Task NoCrash_NoDumpFile_ReturnsSuccess() + { + var installPath = Path.Combine(_testBasePath, "HealthyApp"); + var failDir = Path.Combine(installPath, "fail", "4.0.0"); + + // Mock strategy that does NOT write a dump file (simulates healthy exit) + var mockStrategy = new HealthyProcessStrategy(); + + var context = new BowlContext + { + ProcessNameOrId = "HealthyApp.exe", + DumpFileName = "4.0.0_fail.dmp", + FailFileName = "4.0.0_fail.json", + TargetPath = installPath, + FailDirectory = failDir, + BackupDirectory = Path.Combine(installPath, "4.0.0"), + WorkModel = "Upgrade", + ExtendedField = "4.0.0", + TimeoutMs = 5_000, + }; + + var bowl = CreateBowl(mockStrategy); + var result = await bowl.LaunchAsync(context); + + _output.WriteLine($"Healthy — Success: {result.Success}, DumpCaptured: {result.DumpCaptured}"); + + Assert.False(result.DumpCaptured, "No dump should be captured for healthy process"); + Assert.Equal(0, result.ExitCode); + } + + // ---- Test Helpers ---- + + private static Bowl CreateBowl(IBowlStrategy strategy) + { + return new Bowl(strategy, new CrashReporter(), + new NoOpInfoProvider(), new MockEnvironmentProvider()); + } + + /// + /// Mock strategy that simulates a crash by writing a fake dump file + /// and returning procdump-like output lines. + /// + private sealed class MockCrashStrategy : IBowlStrategy + { + private readonly string _dumpPath; + private readonly IReadOnlyList _outputLines; + + public MockCrashStrategy(string dumpPath, IReadOnlyList outputLines) + { + _dumpPath = dumpPath; + _outputLines = outputLines; + } + + public ProcessStartInfo? Prepare(in BowlContext context) + { + // Simulate procdump writing a dump file + Directory.CreateDirectory(Path.GetDirectoryName(_dumpPath)!); + File.WriteAllText(_dumpPath, "FAKE_DUMP_CONTENT"); + return new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/c exit 0", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + } + + public Task PostProcessAsync(in BowlContext context, + ProcessExitResult exitResult, CancellationToken ct) + { + return Task.CompletedTask; + } + } + + /// + /// Mock strategy that simulates a healthy exit (no dump). + /// + private sealed class HealthyProcessStrategy : IBowlStrategy + { + public ProcessStartInfo? Prepare(in BowlContext context) + { + return new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/c exit 0", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + } + + public Task PostProcessAsync(in BowlContext context, + ProcessExitResult exitResult, CancellationToken ct) + { + return Task.CompletedTask; + } + } + + private sealed class NoOpInfoProvider : ISystemInfoProvider + { + public Task ExportAsync(string outputDirectory, CancellationToken ct) + => Task.CompletedTask; + } + + private sealed class MockEnvironmentProvider : IEnvironmentProvider + { + private readonly Dictionary _vars = new(); + + public string? GetVariable(string name) + => _vars.TryGetValue(name, out var v) ? v : null; + + public void SetVariable(string name, string value) + => _vars[name] = value; + } + } +} diff --git a/src/c#/GeneralUpdate.Bowl/GeneralUpdate.Bowl.csproj b/src/c#/GeneralUpdate.Bowl/GeneralUpdate.Bowl.csproj index 58f7538a..23557576 100644 --- a/src/c#/GeneralUpdate.Bowl/GeneralUpdate.Bowl.csproj +++ b/src/c#/GeneralUpdate.Bowl/GeneralUpdate.Bowl.csproj @@ -89,6 +89,9 @@ + + + True From df595628c0da4ebc6cc319ed42b337999c2defeb Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 23 May 2026 17:47:38 +0800 Subject: [PATCH 2/3] fix(Bowl): address all 12 copilot review suggestions From a9d7f52cc7874a28248d70566f4582bc24e6dc02 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 23 May 2026 17:54:17 +0800 Subject: [PATCH 3/3] test(Bowl): preserve crash test files when debugger attached or KEEP_FILES=1 Dispose() now skips cleanup when Debugger.IsAttached or KEEP_FILES env=1, so developers can inspect dump files and crash reports on disk. --- src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs b/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs index f0a6156f..ca35f9cf 100644 --- a/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs +++ b/src/c#/BowlTest/Integration/BowlCrashPipelineTests.cs @@ -30,6 +30,15 @@ public BowlCrashPipelineTests(ITestOutputHelper output) public void Dispose() { + // When debugging, keep files on disk so you can inspect them. + // Set KEEP_FILES=1 env var or attach a debugger to skip cleanup. + if (System.Diagnostics.Debugger.IsAttached || + Environment.GetEnvironmentVariable("KEEP_FILES") == "1") + { + _output.WriteLine($"[KEPT] Test files at: {_testBasePath}"); + return; + } + if (Directory.Exists(_testBasePath)) { try { Directory.Delete(_testBasePath, recursive: true); }