using System.Diagnostics; using System.Runtime.CompilerServices; using Xunit; namespace DevTool.Tests; public sealed class ScriptCommonTests { [Fact] public async Task ResolveRepoRoot_UsesGlobRootHints() { var root = CreateTempDir("sdt-script-root-"); var nested = Path.Combine(root, "src", "app"); Directory.CreateDirectory(nested); await File.WriteAllTextAsync(Path.Combine(root, "sample.sln"), ""); await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """ { "name": "demo", "version": "0.1.0", "workflows": [], "project": { "rootHints": ["*.sln"] } } """); var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))"); Assert.Equal(Path.GetFullPath(root), output.Trim()); } [Fact] public async Task ResolveRepoRoot_UsesDirectoryMarkerHints() { var root = CreateTempDir("sdt-script-root-"); var nested = Path.Combine(root, "child", "leaf"); Directory.CreateDirectory(nested); Directory.CreateDirectory(Path.Combine(root, ".git")); await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """ { "name": "demo", "version": "0.1.0", "workflows": [], "project": { "rootHints": [".git", "package.json"] } } """); var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))"); Assert.Equal(Path.GetFullPath(root), output.Trim()); } [Fact] public async Task ResolveCommand_ExpandsWindowsPathTokens() { if (!OperatingSystem.IsWindows()) return; var root = CreateTempDir("sdt-script-cmd-"); var shimDir = Path.Combine(root, "nodejs"); Directory.CreateDirectory(shimDir); await File.WriteAllTextAsync(Path.Combine(shimDir, "npm.cmd"), "@echo off"); var output = await RunPythonAsync( root, "import script_common; print(script_common.resolve_command('npm'))", new Dictionary { ["NVM_HOME"] = root, ["NVM_SYMLINK"] = shimDir, ["PATH"] = "%NVM_HOME%;%NVM_SYMLINK%", }); Assert.EndsWith("npm.cmd", output.Trim(), StringComparison.OrdinalIgnoreCase); } [Fact] public async Task FindNodeAppRoot_PrefersTauriAppOverRootPackageJson() { var root = CreateTempDir("sdt-script-node-root-"); await File.WriteAllTextAsync(Path.Combine(root, "package.json"), """ { "dependencies": { "left-pad": "1.3.0" } } """); var tauriRoot = Path.Combine(root, "src", "DevTool.Host.Gui", "TauriShell"); Directory.CreateDirectory(Path.Combine(tauriRoot, "src-tauri")); await File.WriteAllTextAsync(Path.Combine(tauriRoot, "src-tauri", "tauri.conf.json"), "{}"); await File.WriteAllTextAsync(Path.Combine(tauriRoot, "package.json"), """ { "scripts": { "tauri": "tauri", "build": "vite build" } } """); var output = await RunPythonAsync( root, "import pathlib, script_common; print(script_common.find_node_app_root(pathlib.Path(r'" + Escape(root) + "'), None))"); Assert.Equal(Path.GetFullPath(tauriRoot), output.Trim()); } private static async Task RunPythonAsync( string workingDir, string script, IReadOnlyDictionary? env = null) { var python = ResolvePython(); var psi = new ProcessStartInfo { FileName = python, WorkingDirectory = workingDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add("-c"); psi.ArgumentList.Add($"import sys; sys.path.insert(0, r'{Escape(Path.Combine(ProjectRepoRoot(), "scripts"))}'); {script}"); if (env is not null) { foreach (var pair in env) psi.Environment[pair.Key] = pair.Value ?? string.Empty; } using var process = new Process { StartInfo = psi }; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); if (process.ExitCode != 0) throw new InvalidOperationException($"Python exited {process.ExitCode}: {stderr}"); return stdout; } 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."); } private static string CreateTempDir(string prefix) { var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("'", "\\'"); private static string ProjectRepoRoot([CallerFilePath] string file = "") { var repoRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, "..", "..")); if (!File.Exists(Path.Combine(repoRoot, "scripts", "script_common.py"))) throw new InvalidOperationException("Could not locate project repo root."); return repoRoot; } }