using System.Diagnostics; using System.Text.Json; using System.Runtime.CompilerServices; using Xunit; namespace DevTool.Tests; public sealed class ScriptSmokeTests { [Fact] public async Task DiagProbe_JsonContract_IsValid() { var python = ResolvePython(); var result = await RunAsync( python, ["scripts/diag.py", "probe", "--tool", "python", "--json"]); Assert.Equal(0, result.ExitCode); using var doc = JsonDocument.Parse(result.StdOut); Assert.True(doc.RootElement.TryGetProperty("tool", out _)); Assert.True(doc.RootElement.TryGetProperty("available", out _)); } [Fact] public async Task BuildAction_InvalidRequirements_PropagatesNonZeroAndJson() { var python = ResolvePython(); var missingReq = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var result = await RunAsync( python, [ "scripts/build.py", "python-pip-install", "--project-root", RepoRoot(), "--requirements", missingReq, "--json" ]); Assert.NotEqual(0, result.ExitCode); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.True(doc.RootElement.TryGetProperty("exit_code", out var code)); Assert.NotEqual(0, code.GetInt32()); Assert.True(doc.RootElement.TryGetProperty("failure_reason", out _)); } [Fact] public async Task BuildAction_DotnetRestore_CommandNotFoundStillReturnsJson() { var python = ResolvePython(); var result = await RunAsync( python, [ "scripts/build.py", "dotnet-restore", "--project-root", RepoRoot(), "--json" ]); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.True(doc.RootElement.TryGetProperty("exit_code", out _)); Assert.True(doc.RootElement.TryGetProperty("status", out _)); } [Fact] public async Task BuildAction_DotnetBuild_AutoSelectsSlnTarget_WhenSingleSlnFound() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var sln = Path.Combine(tempRoot, "sample.sln"); await File.WriteAllTextAsync(sln, "Microsoft Visual Studio Solution File, Format Version 12.00"); var result = await RunAsync( python, [ "scripts/build.py", "dotnet-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.True(doc.RootElement.TryGetProperty("args", out var args)); Assert.Contains(args.EnumerateArray().Select(x => x.GetString()), x => string.Equals(x, sln, StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task BuildAction_DotnetBuild_AutoSelectsSlnxTarget_WhenSingleSlnxFound() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var slnx = Path.Combine(tempRoot, "sample.slnx"); await File.WriteAllTextAsync(slnx, ""); var result = await RunAsync( python, [ "scripts/build.py", "dotnet-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.True(doc.RootElement.TryGetProperty("args", out var args)); Assert.Contains(args.EnumerateArray().Select(x => x.GetString()), x => string.Equals(x, slnx, StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task BuildAction_DotnetBuild_FallsBackToCsproj_WhenNoSolutionFound() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var csproj = Path.Combine(tempRoot, "sample.csproj"); await File.WriteAllTextAsync(csproj, ""); var result = await RunAsync( python, [ "scripts/build.py", "dotnet-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.True(doc.RootElement.TryGetProperty("args", out var args)); Assert.Contains(args.EnumerateArray().Select(x => x.GetString()), x => string.Equals(x, csproj, StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task BuildAction_DotnetBuild_Skips_WhenNoDotnetTargetFound() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var result = await RunAsync( python, [ "scripts/build.py", "dotnet-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); Assert.Equal(0, result.ExitCode); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.Equal("skipped", doc.RootElement.GetProperty("status").GetString()); Assert.Equal("not_applicable_no_dotnet_target", doc.RootElement.GetProperty("skip_reason").GetString()); } [Fact] public async Task BuildAction_NpmBuild_Skips_WhenNoPackageJsonInWorkingDir() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-node-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var result = await RunAsync( python, [ "scripts/build.py", "npm-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); Assert.Equal(0, result.ExitCode); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.Equal("skipped", doc.RootElement.GetProperty("status").GetString()); Assert.Equal("not_applicable_no_package_json", doc.RootElement.GetProperty("skip_reason").GetString()); } [Fact] public async Task BuildAction_NpmBuild_Skips_WhenBuildScriptMissing() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-node-target-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); await File.WriteAllTextAsync(Path.Combine(tempRoot, "package.json"), """{ "name": "demo", "dependencies": { "x": "1.0.0" } }"""); var result = await RunAsync( python, [ "scripts/build.py", "npm-build", "--project-root", tempRoot, "--working-dir", ".", "--json" ]); Assert.Equal(0, result.ExitCode); var jsonText = ExtractLastJsonObject(result.StdOut); using var doc = JsonDocument.Parse(jsonText); Assert.Equal("skipped", doc.RootElement.GetProperty("status").GetString()); Assert.Equal("not_applicable_missing_build_script", doc.RootElement.GetProperty("skip_reason").GetString()); } [Fact] public async Task PublishOutput_SkipsNonApplicableStacks_InGenericRepo() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-publish-output-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); var result = await RunAsync( python, [ "scripts/publish-output.py", "--repo-root", tempRoot ]); Assert.Equal(0, result.ExitCode); Assert.Contains("Publish output workflow complete.", result.StdOut, StringComparison.OrdinalIgnoreCase); Assert.Contains("Skipping sidecar", result.StdOut, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task PublishOutput_SkipsWeb_WhenBuildScriptMissing() { var python = ResolvePython(); var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-publish-output-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); await File.WriteAllTextAsync(Path.Combine(tempRoot, "package.json"), """{ "name": "demo", "scripts": { "test": "echo ok" } }"""); var result = await RunAsync( python, [ "scripts/publish-output.py", "--repo-root", tempRoot ]); Assert.Equal(0, result.ExitCode); Assert.Contains("Skipping web: package.json has no 'build' script.", result.StdOut, StringComparison.OrdinalIgnoreCase); Assert.Contains("Publish output workflow complete.", result.StdOut, StringComparison.OrdinalIgnoreCase); } private static string ResolvePython() { var candidates = OperatingSystem.IsWindows() ? new[] { "python", "py" } : new[] { "python3", "python" }; foreach (var candidate in candidates) { try { using var process = new Process { StartInfo = new ProcessStartInfo { FileName = candidate, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, } }; process.StartInfo.ArgumentList.Add("--version"); process.Start(); process.WaitForExit(2000); if (process.ExitCode == 0) return candidate; } catch { } } throw new InvalidOperationException("Python executable not found for script smoke tests."); } private static async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string command, IReadOnlyList args) { var psi = new ProcessStartInfo { FileName = command, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = RepoRoot(), }; foreach (var arg in args) psi.ArgumentList.Add(arg); using var process = new Process { StartInfo = psi }; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); return (process.ExitCode, stdout, stderr); } private static string RepoRoot([CallerFilePath] string file = "") { var repoRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, "..", "..")); if (!File.Exists(Path.Combine(repoRoot, "scripts", "diag.py"))) throw new InvalidOperationException("Could not locate repo root (scripts/diag.py not found)."); return repoRoot; } private static string ExtractLastJsonObject(string text) { var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); for (var i = lines.Length - 1; i >= 0; i--) { var line = lines[i]; if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) return line; } throw new InvalidOperationException("No JSON object line found in script output."); } }