diff --git a/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj b/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj
new file mode 100644
index 0000000..c495156
--- /dev/null
+++ b/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ enable
+ enable
+ false
+ true
+ Covenant.Setup.Authoring.Tests
+ Covenant.Setup.Authoring.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/authoring-ui.Tests/CovenantSetupToolTests.cs b/authoring-ui.Tests/CovenantSetupToolTests.cs
new file mode 100644
index 0000000..16802a3
--- /dev/null
+++ b/authoring-ui.Tests/CovenantSetupToolTests.cs
@@ -0,0 +1,86 @@
+using Covenant.Setup.Authoring;
+using Xunit;
+
+namespace Covenant.Setup.Authoring.Tests;
+
+public class CovenantSetupToolTests
+{
+ [Fact]
+ public void Find_returns_first_existing_covenant_setup_exe()
+ {
+ using var temp = new TempDirectory();
+ var missing = Path.Combine(temp.Path, "missing");
+ var found = Path.Combine(temp.Path, "found");
+ Directory.CreateDirectory(missing);
+ Directory.CreateDirectory(found);
+ var exe = Path.Combine(found, "covenant-setup.exe");
+ File.WriteAllText(exe, string.Empty);
+
+ var tool = CovenantSetupToolLocator.Find([missing, found]);
+
+ Assert.NotNull(tool);
+ Assert.Equal(exe, tool.Path);
+ }
+
+ [Fact]
+ public void Find_returns_null_when_no_candidate_contains_exe()
+ {
+ using var temp = new TempDirectory();
+ var candidate = Path.Combine(temp.Path, "candidate");
+ Directory.CreateDirectory(candidate);
+
+ var tool = CovenantSetupToolLocator.Find([candidate]);
+
+ Assert.Null(tool);
+ }
+
+ [Fact]
+ public void CreateStartInfo_builds_expected_package_command()
+ {
+ var tool = new CovenantSetupTool(@"C:\tools\covenant-setup.exe");
+ var manifest = @"C:\src\sample app\install.toml";
+ var output = @"C:\out dir";
+
+ var startInfo = CovenantSetupPackager.CreateStartInfo(tool, manifest, output);
+
+ Assert.Equal(tool.Path, startInfo.FileName);
+ Assert.Equal(@"C:\src\sample app", startInfo.WorkingDirectory);
+ Assert.False(startInfo.UseShellExecute);
+ Assert.True(startInfo.CreateNoWindow);
+ Assert.True(startInfo.RedirectStandardOutput);
+ Assert.True(startInfo.RedirectStandardError);
+ Assert.Equal(["--json", "package", manifest, "--output", output], startInfo.ArgumentList);
+ }
+
+ [Fact]
+ public void CreateStartInfo_uses_current_directory_for_relative_manifest()
+ {
+ var tool = new CovenantSetupTool(@"C:\tools\covenant-setup.exe");
+
+ var startInfo = CovenantSetupPackager.CreateStartInfo(tool, "install.toml", "dist");
+
+ Assert.Equal(Environment.CurrentDirectory, startInfo.WorkingDirectory);
+ }
+}
+
+internal sealed class TempDirectory : IDisposable
+{
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "covenant-authoring-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public string Path { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+}
diff --git a/authoring-ui.Tests/MainViewModelTests.cs b/authoring-ui.Tests/MainViewModelTests.cs
new file mode 100644
index 0000000..1354751
--- /dev/null
+++ b/authoring-ui.Tests/MainViewModelTests.cs
@@ -0,0 +1,74 @@
+using Covenant.Setup.Authoring;
+using Xunit;
+
+namespace Covenant.Setup.Authoring.Tests;
+
+public class MainViewModelTests
+{
+ [Fact]
+ public void Constructor_populates_default_manifest_entries()
+ {
+ var viewModel = new MainViewModel(() => null);
+
+ Assert.Equal("Covenant-Setup Sample App", viewModel.AppName);
+ Assert.Contains(viewModel.Directories, directory => directory.Path == @"{LocalAppData}\CovenantSetupSample");
+ Assert.Contains(viewModel.Files, file => file.Source == @"payload\sample_app.cmd");
+ Assert.Contains(viewModel.PurgePaths, path => path == @"{LocalAppData}\CovenantSetupSample");
+ Assert.Contains("app_name = \"Covenant-Setup Sample App\"", viewModel.TomlPreview);
+ }
+
+ [Fact]
+ public void Validate_rejects_unsupported_registry_roots()
+ {
+ var viewModel = new MainViewModel(() => null);
+ viewModel.Registry.Clear();
+ viewModel.PurgeRegistryBranches.Clear();
+ viewModel.AddRegistry(@"HKCR\Software\Bad", "Name", "Value");
+ viewModel.AddPurgeRegistryBranch(@"HKU\Software\Bad");
+
+ var validation = viewModel.Validate();
+
+ Assert.False(validation.IsValid);
+ Assert.Contains(validation.Errors, error => error.Contains(@"HKCR\Software\Bad", StringComparison.Ordinal));
+ Assert.Contains(validation.Errors, error => error.Contains(@"HKU\Software\Bad", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Validate_warns_when_manifest_requires_elevation()
+ {
+ var viewModel = new MainViewModel(() => null)
+ {
+ InstallRootToken = "{ProgramFilesX64}"
+ };
+ viewModel.ApplyDefaults(resetCollections: true);
+
+ var validation = viewModel.Validate();
+
+ Assert.True(validation.IsValid);
+ Assert.Contains(validation.Warnings, warning => warning.Contains("require elevation", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Packaging_is_disabled_when_covenant_setup_is_missing()
+ {
+ var viewModel = new MainViewModel(() => null);
+
+ Assert.False(viewModel.HasCovenantSetupTool);
+ Assert.False(viewModel.CanPackage);
+ Assert.Contains("not found", viewModel.CovenantSetupStatus, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Packaging_is_enabled_only_when_tool_and_valid_manifest_are_present()
+ {
+ var viewModel = new MainViewModel(() => new CovenantSetupTool(@"C:\tools\covenant-setup.exe"));
+
+ Assert.True(viewModel.HasCovenantSetupTool);
+ Assert.True(viewModel.CanPackage);
+
+ viewModel.AppName = string.Empty;
+
+ Assert.True(viewModel.HasValidationErrors);
+ Assert.False(viewModel.CanPackage);
+ }
+}
diff --git a/authoring-ui.Tests/ManifestTomlWriterTests.cs b/authoring-ui.Tests/ManifestTomlWriterTests.cs
new file mode 100644
index 0000000..b64493a
--- /dev/null
+++ b/authoring-ui.Tests/ManifestTomlWriterTests.cs
@@ -0,0 +1,108 @@
+using Covenant.Setup.Authoring;
+using Xunit;
+
+namespace Covenant.Setup.Authoring.Tests;
+
+public class ManifestTomlWriterTests
+{
+ [Fact]
+ public void Write_emits_current_manifest_schema()
+ {
+ var document = new ManifestDocument
+ {
+ AppName = "Sample App",
+ Directories =
+ [
+ new DirectorySpec(@"{LocalAppData}\Sample")
+ ],
+ Files =
+ [
+ new FileSpec(@"payload\app.exe", @"{LocalAppData}\Sample\bin\app.exe")
+ ],
+ Registry =
+ [
+ new RegistrySpec(@"HKCU\Software\Sample", "InstallRoot", @"{LocalAppData}\Sample")
+ ],
+ Shortcuts =
+ [
+ new ShortcutSpec(
+ @"{Desktop}\Sample App.lnk",
+ @"{LocalAppData}\Sample\bin\app.exe",
+ "--profile default",
+ @"{LocalAppData}\Sample",
+ "Launch Sample App")
+ ],
+ Scripts =
+ [
+ new ScriptSpec(
+ "powershell",
+ ["-ExecutionPolicy", "Bypass", "-File", @"payload\post_install.ps1"],
+ @"{LocalAppData}\Sample")
+ ],
+ Purge = new PurgeSpec
+ {
+ RegistryBranches = [@"HKCU\Software\Sample"],
+ Paths = [@"{LocalAppData}\Sample"]
+ }
+ };
+
+ var toml = Normalize(ManifestTomlWriter.Write(document));
+
+ const string expected = """
+app_name = "Sample App"
+
+[[directories]]
+path = "{LocalAppData}\\Sample"
+
+[[files]]
+source = "payload\\app.exe"
+destination = "{LocalAppData}\\Sample\\bin\\app.exe"
+
+[[registry]]
+key = "HKCU\\Software\\Sample"
+name = "InstallRoot"
+value = "{LocalAppData}\\Sample"
+
+[[shortcuts]]
+path = "{Desktop}\\Sample App.lnk"
+target = "{LocalAppData}\\Sample\\bin\\app.exe"
+arguments = "--profile default"
+working_directory = "{LocalAppData}\\Sample"
+description = "Launch Sample App"
+
+[[scripts]]
+command = "powershell"
+args = [
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "payload\\post_install.ps1"
+]
+working_directory = "{LocalAppData}\\Sample"
+
+[purge]
+registry_branches = ["HKCU\\Software\\Sample"]
+paths = ["{LocalAppData}\\Sample"]
+
+""";
+ Assert.Equal(Normalize(expected), toml);
+ }
+
+ [Fact]
+ public void Write_escapes_toml_strings()
+ {
+ var document = new ManifestDocument
+ {
+ AppName = "Quoted \"App\"\nTabbed\tPath",
+ Directories = [new DirectorySpec(@"C:\Apps\Sample")],
+ Purge = new PurgeSpec()
+ };
+
+ var toml = Normalize(ManifestTomlWriter.Write(document));
+
+ Assert.Contains("app_name = \"Quoted \\\"App\\\"\\nTabbed\\tPath\"", toml);
+ Assert.Contains("path = \"C:\\\\Apps\\\\Sample\"", toml);
+ }
+
+ private static string Normalize(string value) => value.Replace("\r\n", "\n");
+}
diff --git a/authoring-ui/Covenant.Setup.Authoring.csproj b/authoring-ui/Covenant.Setup.Authoring.csproj
index 8edd04a..71d81cb 100644
--- a/authoring-ui/Covenant.Setup.Authoring.csproj
+++ b/authoring-ui/Covenant.Setup.Authoring.csproj
@@ -18,4 +18,7 @@
+
+
+
diff --git a/authoring-ui/CovenantSetupTool.cs b/authoring-ui/CovenantSetupTool.cs
index cdbd7f9..27cd4ca 100644
--- a/authoring-ui/CovenantSetupTool.cs
+++ b/authoring-ui/CovenantSetupTool.cs
@@ -15,7 +15,12 @@ internal static class CovenantSetupToolLocator
{
public static CovenantSetupTool? Find()
{
- foreach (var candidate in CandidatePaths())
+ return Find(CandidateDirectories());
+ }
+
+ internal static CovenantSetupTool? Find(IEnumerable candidateDirectories)
+ {
+ foreach (var candidate in CandidatePaths(candidateDirectories))
{
if (File.Exists(candidate))
{
@@ -26,10 +31,10 @@ internal static class CovenantSetupToolLocator
return null;
}
- private static IEnumerable CandidatePaths()
+ private static IEnumerable CandidatePaths(IEnumerable candidateDirectories)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var directory in CandidateDirectories())
+ foreach (var directory in candidateDirectories)
{
var candidate = System.IO.Path.Combine(directory, "covenant-setup.exe");
if (seen.Add(candidate))
@@ -91,22 +96,9 @@ internal static class CovenantSetupPackager
var error = new StringBuilder();
using var process = new Process
{
- StartInfo = new ProcessStartInfo
- {
- FileName = tool.Path,
- WorkingDirectory = System.IO.Path.GetDirectoryName(manifestPath) ?? Environment.CurrentDirectory,
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true
- },
+ StartInfo = CreateStartInfo(tool, manifestPath, outputDirectory),
EnableRaisingEvents = true
};
- process.StartInfo.ArgumentList.Add("--json");
- process.StartInfo.ArgumentList.Add("package");
- process.StartInfo.ArgumentList.Add(manifestPath);
- process.StartInfo.ArgumentList.Add("--output");
- process.StartInfo.ArgumentList.Add(outputDirectory);
process.OutputDataReceived += (_, args) =>
{
@@ -133,4 +125,29 @@ internal static class CovenantSetupPackager
output.ToString().Trim(),
error.ToString().Trim());
}
+
+ internal static ProcessStartInfo CreateStartInfo(
+ CovenantSetupTool tool,
+ string manifestPath,
+ string outputDirectory)
+ {
+ var manifestDirectory = System.IO.Path.GetDirectoryName(manifestPath);
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = tool.Path,
+ WorkingDirectory = string.IsNullOrWhiteSpace(manifestDirectory)
+ ? Environment.CurrentDirectory
+ : manifestDirectory,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ startInfo.ArgumentList.Add("--json");
+ startInfo.ArgumentList.Add("package");
+ startInfo.ArgumentList.Add(manifestPath);
+ startInfo.ArgumentList.Add("--output");
+ startInfo.ArgumentList.Add(outputDirectory);
+ return startInfo;
+ }
}
diff --git a/authoring-ui/MainViewModel.cs b/authoring-ui/MainViewModel.cs
index b6b2bb0..29d6736 100644
--- a/authoring-ui/MainViewModel.cs
+++ b/authoring-ui/MainViewModel.cs
@@ -18,9 +18,16 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged
private string _tomlPreview = string.Empty;
private string _validationSummary = string.Empty;
private bool _hasValidationErrors;
+ private readonly Func _locateCovenantSetupTool;
public MainViewModel()
+ : this(CovenantSetupToolLocator.Find)
{
+ }
+
+ internal MainViewModel(Func locateCovenantSetupTool)
+ {
+ _locateCovenantSetupTool = locateCovenantSetupTool;
Directories.CollectionChanged += CollectionChanged;
Files.CollectionChanged += CollectionChanged;
Registry.CollectionChanged += CollectionChanged;
@@ -170,7 +177,7 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged
public void RefreshCovenantSetupTool()
{
- CovenantSetupTool = CovenantSetupToolLocator.Find();
+ CovenantSetupTool = _locateCovenantSetupTool();
CovenantSetupStatus = CovenantSetupTool is null
? "covenant-setup.exe was not found. Packaging is disabled."
: "Packaging enabled: " + CovenantSetupTool.Path;
diff --git a/authoring-ui/README.md b/authoring-ui/README.md
index d6b55bb..8da2157 100644
--- a/authoring-ui/README.md
+++ b/authoring-ui/README.md
@@ -8,6 +8,12 @@ Run from the repository root:
dotnet run --project .\authoring-ui\Covenant.Setup.Authoring.csproj
```
+Run unit tests from the repository root:
+
+```powershell
+dotnet test .\authoring-ui.Tests\Covenant.Setup.Authoring.Tests.csproj
+```
+
The app edits the current manifest schema:
- `app_name`