using Sdt.Config; using Sdt.Core; using Sdt.Core.Debug; using Sdt.Runner; using System.Text.Json; using Xunit; namespace DevTool.Tests; public sealed class DebugServicesTests { [Fact] public async Task DebugRunner_MissingPrereqDeclined_ReturnsUserDeclined() { var runner = new DebugProfileRunner( new FakeProbeService(false), new FakeInstallerService(true)); var profile = new DebugProfileDefinition { Id = "p1", Label = "Profile", Type = "python", Command = "python", Args = ["--version"], Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] }; var result = await runner.RunAsync( profile, new DevToolConfig(), Directory.GetCurrentDirectory(), verbose: false, confirmInstallAsync: (_, _) => Task.FromResult(false), onOutput: (_, _) => { }); Assert.False(result.Success); Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason); } [Fact] public async Task DiagnosticsBundle_WritesFiles() { var service = new DiagnosticsBundleService(); var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var request = new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: root, SummaryMessage: "failed", OutputLines: ["hello"], WorkflowSteps: [], Probes: [], DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug" }, Config: new DevToolConfig(), StopReason: ExecutionStopReason.CommandFailed); var result = await service.WriteBundleAsync(request); Assert.True(result.Success); Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "summary.json"))); Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "output.log"))); } [Fact] public async Task DiagnosticsBundle_EmptyAllowlist_CapturesNoEnvByDefault() { var service = new DiagnosticsBundleService(); var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var request = new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: root, SummaryMessage: "failed", OutputLines: [], WorkflowSteps: [], Probes: [], DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug", IncludeAllEnv = false, CaptureEnvKeys = [] }, Config: new DevToolConfig(), StopReason: ExecutionStopReason.CommandFailed); var result = await service.WriteBundleAsync(request); Assert.True(result.Success); var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json")); using var doc = JsonDocument.Parse(envJson); Assert.Empty(doc.RootElement.EnumerateObject()); } [Fact] public async Task DiagnosticsBundle_Allowlist_CapturesOnlyListedKeys() { var service = new DiagnosticsBundleService(); var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); Environment.SetEnvironmentVariable("SDT_TEST_ENV_A", "A"); Environment.SetEnvironmentVariable("SDT_TEST_ENV_B", "B"); var request = new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: root, SummaryMessage: "failed", OutputLines: [], WorkflowSteps: [], Probes: [], DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug", IncludeAllEnv = false, CaptureEnvKeys = ["SDT_TEST_ENV_A"] }, Config: new DevToolConfig(), StopReason: ExecutionStopReason.CommandFailed); var result = await service.WriteBundleAsync(request); Assert.True(result.Success); var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json")); using var doc = JsonDocument.Parse(envJson); Assert.True(doc.RootElement.TryGetProperty("SDT_TEST_ENV_A", out _)); Assert.False(doc.RootElement.TryGetProperty("SDT_TEST_ENV_B", out _)); } [Fact] public async Task DiagnosticsBundle_RedactsSensitiveEnvKeys() { var service = new DiagnosticsBundleService(); var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); Environment.SetEnvironmentVariable("MY_SECRET_TOKEN", "super-secret"); var request = new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: root, SummaryMessage: "failed", OutputLines: [], WorkflowSteps: [], Probes: [], DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug", IncludeAllEnv = false, CaptureEnvKeys = ["MY_SECRET_TOKEN"], RedactSensitive = true }, Config: new DevToolConfig(), StopReason: ExecutionStopReason.CommandFailed); var result = await service.WriteBundleAsync(request); Assert.True(result.Success); var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json")); using var doc = JsonDocument.Parse(envJson); Assert.Equal("***REDACTED***", doc.RootElement.GetProperty("MY_SECRET_TOKEN").GetString()); } [Fact] public async Task DiagnosticsBundle_RedactsSensitiveOutputPatterns() { var service = new DiagnosticsBundleService(); var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var request = new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: root, SummaryMessage: "failed", OutputLines: ["token=abc123", "Authorization: Bearer verysecretvalue"], WorkflowSteps: [], Probes: [], DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug", RedactSensitive = true }, Config: new DevToolConfig(), StopReason: ExecutionStopReason.CommandFailed); var result = await service.WriteBundleAsync(request); Assert.True(result.Success); var output = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "output.log")); Assert.Contains("***REDACTED***", output, StringComparison.Ordinal); Assert.DoesNotContain("verysecretvalue", output, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task DebugRunner_EmitsRunEvents() { var runner = new DebugProfileRunner( new FakeProbeService(true), new FakeInstallerService(true)); var profile = new DebugProfileDefinition { Id = "p1", Label = "Profile", Type = "python", Command = "python", Args = ["--version"], Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] }; var events = new List(); var result = await runner.RunAsync( profile, new DevToolConfig(), Directory.GetCurrentDirectory(), verbose: false, confirmInstallAsync: (_, _) => Task.FromResult(false), onOutput: (_, _) => { }, onEvent: events.Add); Assert.True(result.Success); Assert.Contains(events, e => e.Type == RunEventType.DebugStarted); Assert.Contains(events, e => e.Type == RunEventType.DebugCommandStarted); Assert.Contains(events, e => e.Type == RunEventType.DebugCommandCompleted); Assert.Contains(events, e => e.Type == RunEventType.DebugCompleted && e.Success == true); } private sealed class FakeProbeService(bool isAvailable) : IToolProbe { public Task ProbeAsync( string tool, string projectRoot, DevToolConfig? config = null, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null)); } private sealed class FakeInstallerService(bool success) : IPrereqInstaller { public Task GetInstallPlanAsync( string tool, string projectRoot, DevToolConfig? config = null, CancellationToken cancellationToken = default) => Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])])); public Task RunInstallAsync( InstallCommand command, string projectRoot, Action onOutput, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5))); } }