diff --git a/CLAUDE.md b/CLAUDE.md index b1d1608..1b90ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,4 +72,4 @@ the example manifest (`examples/Covenant-SetupSampleApp-install.toml`). ## VM coverage harness -`scripts\run-windows-vm-coverage.ps1` walks every scenario directory under `vm\\install.toml` and delegates per-scenario in-guest assertions to `scripts\windows-vm\coverage\.ps1`. The bundled scenarios (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) exercise the elevation, MoveFileEx pending-rename, HKLM-registry, and embedded-bundle code paths. The harness builds the release binary and dispatches scenarios in-place; pass `-SkipBuild` to reuse a prior build. +`scripts\run-windows-vm-coverage.ps1` walks every scenario directory under `vm\\-install.toml` (the manifest file name is discovered per scenario) and delegates per-scenario in-guest assertions to `scripts\windows-vm\coverage\.ps1`. The bundled scenarios (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) exercise the elevation, MoveFileEx pending-rename, HKLM-registry, and embedded-bundle code paths. The harness builds the release binary and dispatches scenarios in-place; pass `-SkipBuild` to reuse a prior build. diff --git a/README.md b/README.md index f39cfcb..623119d 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ The current manifest supports: - `scripts` - `purge` -Only `app_name` and shortcut `description` may contain spaces in manifest content. The manifest file path and every other manifest string field must be whitespace-free. +Only `app_name` and shortcut `description` may contain spaces in manifest content. The manifest file name and every other manifest string field must be whitespace-free. The `package` command additionally rejects spaces anywhere in the manifest path; `install` accepts any directory, because bundled installs extract the manifest under `%TEMP%`, which contains spaces for user profiles like `C:\Users\First Last`. The sample manifest lives at [`examples/Covenant-SetupSampleApp-install.toml`](C:\Users\jasonross\workspace\covenant-setup\examples\Covenant-SetupSampleApp-install.toml). @@ -177,7 +177,7 @@ Caveats: A Windows Hyper-V Vagrant VM now lives in [`Vagrantfile`](C:\Users\jasonross\workspace\covenant-setup\Vagrantfile), and the host harness in [`scripts/run-windows-vm-smoke.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-smoke.ps1) packages `covenant-setup`, boots the VM, opens Hyper-V's console viewer, and runs the packaged installer inside the guest's interactive desktop session. -The self-install manifest used for this path lives at [`vm/self-test/install.toml`](C:\Users\jasonross\workspace\covenant-setup\vm\self-test\install.toml). The guest verifies that install produced: +The self-install manifest used for this path lives at [`vm/self-test/CovenantSetupSelfTest-install.toml`](C:\Users\jasonross\workspace\covenant-setup\vm\self-test\CovenantSetupSelfTest-install.toml). The guest verifies that install produced: - `%LOCALAPPDATA%\CovenantSetupSelfTest\bin\covenant-setup.exe` - `%LOCALAPPDATA%\CovenantSetupSelfTest\journal.json` @@ -210,5 +210,5 @@ Notes: ### VM Coverage Harness -In addition to the single-scenario smoke test, [`scripts/run-windows-vm-coverage.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-coverage.ps1) walks every scenario manifest under `vm\\install.toml` (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) and delegates the in-guest assertions to [`scripts\windows-vm\coverage\.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\windows-vm\coverage). The scenarios exercise the elevation, MoveFileEx pending-rename / Restart Manager, HKLM-only registry, and bundled embedded-installer code paths that the unit tests stub out via `MockSys`. +In addition to the single-scenario smoke test, [`scripts/run-windows-vm-coverage.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-coverage.ps1) walks every scenario manifest under `vm\\-install.toml` (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) and delegates the in-guest assertions to [`scripts\windows-vm\coverage\.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\windows-vm\coverage). The scenarios exercise the elevation, MoveFileEx pending-rename / Restart Manager, HKLM-only registry, and bundled embedded-installer code paths that the unit tests stub out via `MockSys`. - Use `-HaltAfter` or `-DestroyAfter` if you want the harness to stop the VM after the test run. diff --git a/Vagrantfile b/Vagrantfile index ff4729a..df5b20a 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,6 +1,9 @@ WINDOWS_BOX = ENV.fetch("COVENANT_WINDOWS_BOX", "gusztavvargadr/windows-11") WINDOWS_BOX_VERSION = ENV["COVENANT_WINDOWS_BOX_VERSION"] VM_NAME = ENV.fetch("COVENANT_VM_NAME", "covenant-setup-windows") +# Guest hostname must fit the 15-character NetBIOS limit, or Windows truncates +# it and Vagrant re-attempts the rename (with a reboot) on every up/reload. +VM_HOSTNAME = ENV.fetch("COVENANT_VM_HOSTNAME", "covenant-setup") VM_MEMORY = ENV.fetch("COVENANT_VM_MEMORY", "6144") VM_CPUS = ENV.fetch("COVENANT_VM_CPUS", "4") WINRM_USERNAME = ENV.fetch("COVENANT_WINRM_USERNAME", "vagrant") @@ -13,7 +16,7 @@ Vagrant.configure("2") do |config| config.vm.box_version = WINDOWS_BOX_VERSION end - config.vm.hostname = VM_NAME + config.vm.hostname = VM_HOSTNAME config.vm.guest = :windows config.vm.communicator = "winrm" config.vm.boot_timeout = ENV.fetch("COVENANT_BOOT_TIMEOUT", "1800").to_i diff --git a/authoring-ui.Tests/AuthoringUiAutomationTests.cs b/authoring-ui.Tests/AuthoringUiAutomationTests.cs new file mode 100644 index 0000000..9a822dc --- /dev/null +++ b/authoring-ui.Tests/AuthoringUiAutomationTests.cs @@ -0,0 +1,329 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.UIA3; +using Xunit; + +namespace Covenant.Setup.Authoring.Tests; + +public class AuthoringUiAutomationTests +{ + private string GetExePath() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "Covenant.Setup.slnx"))) + { + break; + } + dir = Path.GetDirectoryName(dir); + } + if (string.IsNullOrEmpty(dir)) + { + throw new DirectoryNotFoundException("Could not find repository root containing Covenant.Setup.slnx"); + } + var rootDir = dir; + var exePath = Path.Combine(rootDir, "authoring-ui", "bin", "Debug", "net10.0-windows10.0.19041.0", "win-x64", "Covenant.Setup.Authoring.exe"); + if (!File.Exists(exePath)) + { + exePath = Path.Combine(rootDir, "dist", "authoring-ui", "Covenant.Setup.Authoring.exe"); + } + return exePath; + } + + private static string DumpDescendants(AutomationElement root) + { + var sb = new StringBuilder(); + DumpDescendantsInternal(root, sb, 0); + return sb.ToString(); + } + + private static void DumpDescendantsInternal(AutomationElement element, StringBuilder sb, int indent) + { + if (indent > 12) return; + var indentStr = new string(' ', indent * 2); + + string name = "[Unknown]"; + string id = "[Unknown]"; + string className = "[Unknown]"; + + try { name = element.Name; } catch {} + try { id = element.AutomationId; } catch {} + try { className = element.ClassName; } catch {} + + sb.AppendLine($"{indentStr}- Name: '{name}', Id: '{id}', Class: '{className}'"); + + try + { + var children = element.FindAllChildren(); + if (children != null) + { + foreach (var child in children) + { + if (child != null) + { + DumpDescendantsInternal(child, sb, indent + 1); + } + } + } + } + catch (System.Exception ex) + { + sb.AppendLine($"{indentStr} [Error listing children: {ex.Message}]"); + } + } + + private static AutomationElement FindElement(Window window, string automationId, int timeoutMs = 5000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + var el = window.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (el != null) + { + return el; + } + Thread.Sleep(100); + } + throw new Exception($"Failed to find element with AutomationId '{automationId}' after {timeoutMs}ms.\nVisual Tree:\n{DumpDescendants(window)}"); + } + + private static AutomationElement FindElementAcrossWindows(Application app, AutomationBase automation, string automationId, int timeoutMs = 5000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + var windows = app.GetAllTopLevelWindows(automation); + foreach (var w in windows) + { + var el = w.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (el != null) + { + return el; + } + } + Thread.Sleep(100); + } + throw new Exception($"Failed to find element with AutomationId '{automationId}' across all windows after {timeoutMs}ms."); + } + + private static Window GetMainWindow(Application app, AutomationBase automation, string titlePart, int timeoutMs = 15000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + var windows = app.GetAllTopLevelWindows(automation); + var target = windows.FirstOrDefault(w => w.Title.Contains(titlePart, StringComparison.OrdinalIgnoreCase)); + if (target != null) + { + return target; + } + Thread.Sleep(250); + } + throw new Exception($"Failed to find window with title containing '{titlePart}' after {timeoutMs}ms. Windows found: {string.Join(", ", app.GetAllTopLevelWindows(automation).Select(w => $"'{w.Title}' ({w.ClassName})"))}"); + } + + private static void ClickElement(AutomationElement element) + { + if (element is Button button) + { + button.Invoke(); + } + else if (element.Patterns.Invoke.IsSupported) + { + element.Patterns.Invoke.Pattern.Invoke(); + } + else if (element.Patterns.Toggle.IsSupported) + { + element.Patterns.Toggle.Pattern.Toggle(); + } + else if (element.Patterns.SelectionItem.IsSupported) + { + element.Patterns.SelectionItem.Pattern.Select(); + } + else + { + element.Click(false); + } + } + + [Fact] + public void TestAllInteractiveContent() + { + var exePath = GetExePath(); + Assert.True(File.Exists(exePath), $"Executable not found at: {exePath}. Build the project first."); + + // The expected filename for AppName "FlaUI Test App" is "FlaUITestApp-install.toml" + var tempManifestPath = Path.Combine(AppContext.BaseDirectory, "FlaUITestApp-install.toml"); + if (File.Exists(tempManifestPath)) + { + File.Delete(tempManifestPath); + } + + // We run the app passing the --manifest-path and --tool-path arguments to bypass file dialogs + var dummyToolPath = Path.Combine(AppContext.BaseDirectory, "dummy-covenant-setup.exe"); + File.WriteAllText(dummyToolPath, "dummy executable"); + + var appArgs = $"--manifest-path \"{tempManifestPath}\" --tool-path \"{dummyToolPath}\""; + + using (var app = Application.Launch(exePath, appArgs)) + { + using (var automation = new UIA3Automation()) + { + var window = GetMainWindow(app, automation, "Manifest Authoring"); + Assert.NotNull(window); + + // Wait a moment for window initialization + Thread.Sleep(1000); + + // 1. Edit App Name + var appNameBox = FindElement(window, "AppName").AsTextBox(); + appNameBox.Text = "FlaUI Test App"; + + // 2. Edit Application Target Folder + var folderBox = FindElement(window, "ApplicationFolder").AsTextBox(); + folderBox.Text = "FlaUITestFolder"; + + // 3. Edit Primary Payload + var payloadBox = FindElement(window, "PrimaryPayload").AsTextBox(); + payloadBox.Text = @"payload\testapp.exe"; + + // 4. Select Install Root Token Combo + var rootCombo = FindElement(window, "InstallRootTokenCombo").AsComboBox(); + rootCombo.Select(0); + Thread.Sleep(300); + + // 5. Add a Directory Path + var dirPathBox = FindElement(window, "DirectoryPathBox").AsTextBox(); + dirPathBox.Text = @"{LocalAppData}\FlaUITestFolder\subdir"; + + var addDirButton = FindElement(window, "AddDirectoryButton").AsButton(); + ClickElement(addDirButton); + Thread.Sleep(200); + + // 6. Add a File + var fileSourceBox = FindElement(window, "FileSourceBox").AsTextBox(); + fileSourceBox.Text = @"payload\extra.dll"; + + var fileDestBox = FindElement(window, "FileDestinationBox").AsTextBox(); + fileDestBox.Text = @"{LocalAppData}\FlaUITestFolder\extra.dll"; + + var addFileButton = FindElement(window, "AddFileButton").AsButton(); + ClickElement(addFileButton); + Thread.Sleep(200); + + // 7. Add a Registry entry + var regKeyBox = FindElement(window, "RegistryKeyBox").AsTextBox(); + regKeyBox.Text = @"HKCU\Software\FlaUITestApp"; + + var regNameBox = FindElement(window, "RegistryNameBox").AsTextBox(); + regNameBox.Text = "Version"; + + var regValBox = FindElement(window, "RegistryValueBox").AsTextBox(); + regValBox.Text = "1.0.0"; + + var addRegButton = FindElement(window, "AddRegistryButton").AsButton(); + ClickElement(addRegButton); + Thread.Sleep(200); + + // 8. Edit Shortcut Description + var shortcutDescBox = FindElement(window, "ShortcutDescription").AsTextBox(); + shortcutDescBox.Text = "Launch FlaUI Test App"; + + // 9. Add a Script + var scriptCmdBox = FindElement(window, "ScriptCommandBox").AsTextBox(); + scriptCmdBox.Text = "cmd.exe"; + + var scriptArgsBox = FindElement(window, "ScriptArgsBox").AsTextBox(); + scriptArgsBox.Text = $"/c{System.Environment.NewLine}echo"; + + var scriptWorkDirBox = FindElement(window, "ScriptWorkingDirBox").AsTextBox(); + scriptWorkDirBox.Text = @"{LocalAppData}\FlaUITestFolder"; + + var addScriptButton = FindElement(window, "AddScriptButton").AsButton(); + ClickElement(addScriptButton); + Thread.Sleep(200); + + // 10. Toggle Theme + var themeToggle = FindElement(window, "ThemeToggle"); + ClickElement(themeToggle); + Thread.Sleep(200); + + // 11. Verify TOML Preview + var previewBox = FindElement(window, "PreviewBox").AsTextBox(); + var previewText = previewBox.Text; + Assert.Contains("app_name = 'FlaUI Test App'", previewText); + Assert.Contains("FlaUITestFolder", previewText); + Assert.Contains("testapp.exe", previewText); + Assert.Contains("subdir", previewText); + Assert.Contains("extra.dll", previewText); + Assert.Contains("FlaUITestApp", previewText); + + // 12. Validate + var validateButton = FindElement(window, "ValidateButton").AsButton(); + ClickElement(validateButton); + Thread.Sleep(500); + + // Find the ContentDialog and close it (should have a CloseButton) + var okButton = FindElementAcrossWindows(app, automation, "CloseButton").AsButton(); + ClickElement(okButton); + Thread.Sleep(300); + + // 13. Installer Config Dialog + var configButton = FindElement(window, "InstallerConfigButton").AsButton(); + ClickElement(configButton); + Thread.Sleep(500); + + // Find the config inputs inside the ContentDialog + var configPathBox = FindElementAcrossWindows(app, automation, "CovenantSetupPathBox").AsTextBox(); + configPathBox.Text = dummyToolPath; + + var outputDirBox = FindElementAcrossWindows(app, automation, "OutputDirectoryBox").AsTextBox(); + outputDirBox.Text = AppContext.BaseDirectory; + + var saveAndCloseButton = FindElementAcrossWindows(app, automation, "PrimaryButton").AsButton(); + if (!saveAndCloseButton.IsEnabled) + { + var statusEl = FindElement(window, "ValidationSummaryText").AsLabel(); + throw new System.Exception($"Save and Close button is disabled! Validation Summary: '{statusEl.Text}'"); + } + ClickElement(saveAndCloseButton); + Thread.Sleep(300); + + // 14. Save TOML (this saves manifest to tempManifestPath without dialog, because we configured --manifest-path) + var saveButton = FindElement(window, "SaveButton").AsButton(); + ClickElement(saveButton); + Thread.Sleep(500); + + // Confirm save dialog OK click + var saveOkButton = FindElementAcrossWindows(app, automation, "CloseButton").AsButton(); + ClickElement(saveOkButton); + Thread.Sleep(300); + + Assert.True(File.Exists(tempManifestPath), "Manifest was not written to output path."); + var writtenManifest = File.ReadAllText(tempManifestPath); + Assert.Contains("app_name = 'FlaUI Test App'", writtenManifest); + + // Close application + app.Close(); + } + } + + // Clean up + if (File.Exists(tempManifestPath)) + { + File.Delete(tempManifestPath); + } + if (File.Exists(dummyToolPath)) + { + File.Delete(dummyToolPath); + } + } +} diff --git a/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj b/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj index c495156..3d2d7ba 100644 --- a/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj +++ b/authoring-ui.Tests/Covenant.Setup.Authoring.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/authoring-ui/InstallerConfigDialog.xaml b/authoring-ui/InstallerConfigDialog.xaml index 0b5e97e..a7cdfd7 100644 --- a/authoring-ui/InstallerConfigDialog.xaml +++ b/authoring-ui/InstallerConfigDialog.xaml @@ -40,6 +40,7 @@ Grid.Column="0" MinWidth="360" Header="Covenant Setup Executable" + AutomationProperties.AutomationId="CovenantSetupPathBox" PlaceholderText="C:\path\to\covenant-setup.exe" Text="{Binding CovenantSetupPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> @@ -48,6 +49,7 @@ Grid.Column="1" MinWidth="96" VerticalAlignment="Bottom" + AutomationProperties.AutomationId="BrowseCovenantSetupButton" Content="Browse" Click="BrowseCovenantSetupButton_Click" /> @@ -56,6 +58,7 @@ Grid.Column="0" MinWidth="360" Header="Output Directory" + AutomationProperties.AutomationId="OutputDirectoryBox" PlaceholderText="C:\path\to\dist" Text="{Binding OutputDirectory, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> @@ -64,6 +67,7 @@ Grid.Column="1" MinWidth="96" VerticalAlignment="Bottom" + AutomationProperties.AutomationId="ChooseOutputDirectoryButton" Content="Choose" Click="ChooseOutputDirectoryButton_Click" /> diff --git a/authoring-ui/InstallerConfigViewModel.cs b/authoring-ui/InstallerConfigViewModel.cs index a71b768..152706a 100644 --- a/authoring-ui/InstallerConfigViewModel.cs +++ b/authoring-ui/InstallerConfigViewModel.cs @@ -248,6 +248,27 @@ internal sealed class InstallerConfigViewModel : INotifyPropertyChanged HasManifestValidationErrors = hasErrors; } + /// + /// Seeds the dialog from the owning view model's committed state so the + /// main window stays the single source of truth: Close discards edits, + /// Save and Close commits them back. + /// + public void SyncFrom(CovenantSetupTool? tool, string outputDirectory) + { + if (tool is null) + { + SyncToolPath(string.Empty); + ClearValidatedTool(MissingToolStatus); + } + else + { + AcceptValidatedTool(tool); + } + + OutputDirectory = outputDirectory; + SetMessage(string.Empty, InstallerConfigStatusKind.Neutral); + } + public async Task SaveConfigAsync(CancellationToken cancellationToken = default) { if (IsBuilding) diff --git a/authoring-ui/MainViewModel.cs b/authoring-ui/MainViewModel.cs index 74922ef..d9a075a 100644 --- a/authoring-ui/MainViewModel.cs +++ b/authoring-ui/MainViewModel.cs @@ -522,8 +522,9 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged private string ShortcutFileName(string target) { - var fileName = FileNameWithoutExtension(target); - var sanitized = SanitizeIdentifier(fileName); + // The empty-checked variant keeps the folder/app-name fallback + // reachable when the target file name sanitizes to nothing. + var sanitized = SanitizeIdentifierOrEmpty(FileNameWithoutExtension(target)); if (!string.IsNullOrWhiteSpace(sanitized)) { return sanitized; @@ -678,10 +679,13 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged private static string SanitizeIdentifier(string value) { - var sanitized = IdentifierRegex().Replace(value.Trim(), "_").Trim('_'); + var sanitized = SanitizeIdentifierOrEmpty(value); return string.IsNullOrWhiteSpace(sanitized) ? "covenant_setup" : sanitized; } + private static string SanitizeIdentifierOrEmpty(string value) => + IdentifierRegex().Replace(value.Trim(), "_").Trim('_'); + internal static string ManifestFileName(string appName) { var name = new string(appName.Trim().Where(ch => !char.IsWhiteSpace(ch)).ToArray()); diff --git a/authoring-ui/MainWindow.cs b/authoring-ui/MainWindow.cs index e9261e1..942f11c 100644 --- a/authoring-ui/MainWindow.cs +++ b/authoring-ui/MainWindow.cs @@ -54,6 +54,21 @@ internal sealed class MainWindow : Window public MainWindow() { Title = "Covenant Setup Manifest Authoring"; + + // Parse command line arguments for testing (to avoid interactive file pickers) + var args = Environment.GetCommandLineArgs(); + for (int i = 1; i < args.Length - 1; i++) + { + if (args[i] == "--manifest-path") + { + _lastSavedManifestPath = args[i + 1]; + } + else if (args[i] == "--tool-path") + { + _viewModel.SetCovenantSetupTool(new CovenantSetupTool(args[i + 1])); + } + } + Content = BuildContent(); ApplyInitialTheme(); ConfigureWindow(); @@ -117,6 +132,7 @@ internal sealed class MainWindow : Window TextWrapping = TextWrapping.Wrap, MaxLines = 3 }; + AutomationProperties.SetAutomationId(_statusText, "ValidationSummaryText"); _statusText.SetBinding( TextBlock.TextProperty, new Binding @@ -183,23 +199,28 @@ internal sealed class MainWindow : Window OffContent = string.Empty, MinWidth = 72 }; + AutomationProperties.SetAutomationId(_themeToggle, "ThemeToggle"); _themeToggle.Toggled += (_, _) => ToggleTheme(); themeControl.Children.Add(_themeToggle); actions.Children.Add(themeControl); var validateButton = new Button { Content = "Validate", MinWidth = 92 }; + AutomationProperties.SetAutomationId(validateButton, "ValidateButton"); validateButton.Click += async (_, _) => await ShowValidationAsync(); actions.Children.Add(validateButton); var installerConfigButton = new Button { Content = "Installer Config", MinWidth = 124 }; + AutomationProperties.SetAutomationId(installerConfigButton, "InstallerConfigButton"); installerConfigButton.Click += async (_, _) => await ShowInstallerConfigAsync(); actions.Children.Add(installerConfigButton); _generateInstallerButton = new Button { Content = "Save and Build", MinWidth = 124 }; + AutomationProperties.SetAutomationId(_generateInstallerButton, "SaveAndBuildButton"); _generateInstallerButton.Click += async (_, _) => await GenerateInstallerAsync(); actions.Children.Add(_generateInstallerButton); var saveButton = new Button { Content = "Save TOML", MinWidth = 104 }; + AutomationProperties.SetAutomationId(saveButton, "SaveButton"); saveButton.Click += async (_, _) => await SaveManifestAsync(); actions.Children.Add(saveButton); @@ -251,6 +272,7 @@ internal sealed class MainWindow : Window }); _copyPreviewButton = new Button { Content = "Copy", MinWidth = 80 }; + AutomationProperties.SetAutomationId(_copyPreviewButton, "CopyPreviewButton"); AutomationProperties.SetName(_copyPreviewButton, "Copy TOML preview"); _copyPreviewButton.Click += async (_, _) => await CopyPreviewAsync(); Grid.SetColumn(_copyPreviewButton, 1); @@ -267,6 +289,7 @@ internal sealed class MainWindow : Window IsReadOnly = true, TextWrapping = TextWrapping.NoWrap }; + AutomationProperties.SetAutomationId(_previewBox, "PreviewBox"); _previewBox.SetBinding( TextBox.TextProperty, new Binding @@ -294,6 +317,7 @@ internal sealed class MainWindow : Window SelectedItem = _viewModel.InstallRootToken, MinWidth = 180 }; + AutomationProperties.SetAutomationId(rootCombo, "InstallRootTokenCombo"); rootCombo.SelectionChanged += (_, _) => { if (rootCombo.SelectedItem is string token) @@ -314,6 +338,7 @@ internal sealed class MainWindow : Window private FrameworkElement BuildDirectoriesSection() { var pathBox = new TextBox(); + AutomationProperties.SetAutomationId(pathBox, "DirectoryPathBox"); pathBox.SetBinding( TextBox.PlaceholderTextProperty, new Binding @@ -323,6 +348,7 @@ internal sealed class MainWindow : Window }); var rows = RemovableRows(_viewModel.Directories); var addButton = new Button { Content = "Add Path", MinWidth = 88 }; + AutomationProperties.SetAutomationId(addButton, "AddDirectoryButton"); addButton.Click += (_, _) => { _viewModel.AddDirectory(pathBox.Text); @@ -338,7 +364,9 @@ internal sealed class MainWindow : Window private FrameworkElement BuildFilesSection() { var sourceBox = new TextBox { PlaceholderText = @"payload\app.exe" }; + AutomationProperties.SetAutomationId(sourceBox, "FileSourceBox"); var destinationBox = new TextBox(); + AutomationProperties.SetAutomationId(destinationBox, "FileDestinationBox"); destinationBox.SetBinding( TextBox.PlaceholderTextProperty, new Binding @@ -348,6 +376,7 @@ internal sealed class MainWindow : Window }); var rows = RemovableRows(_viewModel.Files); var addButton = new Button { Content = "Add", MinWidth = 72 }; + AutomationProperties.SetAutomationId(addButton, "AddFileButton"); addButton.Click += async (_, _) => { if (string.IsNullOrWhiteSpace(sourceBox.Text) || string.IsNullOrWhiteSpace(destinationBox.Text)) @@ -371,8 +400,11 @@ internal sealed class MainWindow : Window private FrameworkElement BuildRegistrySection() { var keyBox = new TextBox { PlaceholderText = @"HKCU\Software\VendorApp" }; + AutomationProperties.SetAutomationId(keyBox, "RegistryKeyBox"); var nameBox = new TextBox { PlaceholderText = "InstallRoot" }; + AutomationProperties.SetAutomationId(nameBox, "RegistryNameBox"); var valueBox = new TextBox(); + AutomationProperties.SetAutomationId(valueBox, "RegistryValueBox"); valueBox.SetBinding( TextBox.PlaceholderTextProperty, new Binding @@ -382,6 +414,7 @@ internal sealed class MainWindow : Window }); var rows = RemovableRows(_viewModel.Registry); var addButton = new Button { Content = "Add", MinWidth = 72 }; + AutomationProperties.SetAutomationId(addButton, "AddRegistryButton"); addButton.Click += async (_, _) => { if (string.IsNullOrWhiteSpace(keyBox.Text) || @@ -418,13 +451,16 @@ internal sealed class MainWindow : Window private FrameworkElement BuildScriptsSection() { var commandBox = new TextBox { PlaceholderText = "powershell" }; + AutomationProperties.SetAutomationId(commandBox, "ScriptCommandBox"); var argsBox = new TextBox { AcceptsReturn = true, Height = 76, PlaceholderText = "-ExecutionPolicy" }; + AutomationProperties.SetAutomationId(argsBox, "ScriptArgsBox"); var workingDirectoryBox = new TextBox(); + AutomationProperties.SetAutomationId(workingDirectoryBox, "ScriptWorkingDirBox"); workingDirectoryBox.SetBinding( TextBox.PlaceholderTextProperty, new Binding @@ -434,6 +470,7 @@ internal sealed class MainWindow : Window }); var rows = RemovableRows(_viewModel.Scripts); var addButton = new Button { Content = "Add", MinWidth = 72 }; + AutomationProperties.SetAutomationId(addButton, "AddScriptButton"); addButton.Click += async (_, _) => { if (string.IsNullOrWhiteSpace(commandBox.Text)) @@ -464,6 +501,9 @@ internal sealed class MainWindow : Window { _installerConfigViewModel.SetManifestValidationState(_viewModel.HasValidationErrors); _installerConfigViewModel.IsBuilding = _isPackaging; + // The main view model owns the committed tool/output state; the dialog + // is a transient editor seeded on open so Close discards edits. + _installerConfigViewModel.SyncFrom(_viewModel.CovenantSetupTool, _viewModel.OutputDirectory); var dialog = new InstallerConfigDialog( _installerConfigViewModel, @@ -556,38 +596,44 @@ internal sealed class MainWindow : Window private async Task GenerateInstallerAsync(Func? showMessageAsync = null) { showMessageAsync ??= ShowNoticeAsync; - var tool = _viewModel.CovenantSetupTool; - if (tool is null) - { - await showMessageAsync("Installer EXE", "covenant-setup.exe was not found. Packaging is disabled."); - return; - } - - var validation = _viewModel.Validate(); - if (!validation.IsValid) - { - await showMessageAsync("Validation", string.Join(Environment.NewLine, validation.Errors)); - return; - } - - if (string.IsNullOrWhiteSpace(_viewModel.OutputDirectory)) - { - await showMessageAsync("Installer EXE", "Choose an output directory before packaging."); - return; - } - - var manifestPath = await WriteManifestWithPickerAsync(showNotice: false, showMessageAsync); - if (manifestPath is null) + // The packaging flag must be set before the first await so a second + // click cannot start a concurrent run against the same output EXE. + if (_isPackaging) { return; } - _lastSavedManifestPath = manifestPath; - _isPackaging = true; _installerConfigViewModel.IsBuilding = true; RefreshPackageControls(); try { + var tool = _viewModel.CovenantSetupTool; + if (tool is null) + { + await showMessageAsync("Installer EXE", "covenant-setup.exe was not found. Packaging is disabled."); + return; + } + + var validation = _viewModel.Validate(); + if (!validation.IsValid) + { + await showMessageAsync("Validation", string.Join(Environment.NewLine, validation.Errors)); + return; + } + + if (string.IsNullOrWhiteSpace(_viewModel.OutputDirectory)) + { + await showMessageAsync("Installer EXE", "Choose an output directory before packaging."); + return; + } + + var manifestPath = await WriteManifestWithPickerAsync(showNotice: false, showMessageAsync); + if (manifestPath is null) + { + return; + } + _lastSavedManifestPath = manifestPath; + var result = await CovenantSetupPackager.PackageAsync( tool, manifestPath, @@ -1140,6 +1186,7 @@ internal sealed class MainWindow : Window private static TextBox BoundTextBox(string propertyName, string placeholder) { var box = new TextBox { PlaceholderText = placeholder }; + AutomationProperties.SetAutomationId(box, propertyName); box.SetBinding( TextBox.TextProperty, new Binding @@ -1375,7 +1422,7 @@ internal sealed class MainWindow : Window private static IReadOnlyList SplitLines(string value) => value.Split( - [Environment.NewLine, "\n"], + [Environment.NewLine, "\n", "\r"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private sealed record ThemeBrushes( diff --git a/build.rs b/build.rs index 74d2615..b2e7b24 100644 --- a/build.rs +++ b/build.rs @@ -17,9 +17,13 @@ fn main() { fn publish_csharp_ui() { println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/App.xaml"); println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Program.cs"); println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/InstallerUiWindow.cs"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/InstallerSessionController.cs"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/IInstallerView.cs"); println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/UiProtocol.cs"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Mocking"); println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/app.manifest"); let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); diff --git a/docs/integration-tests-architecture.md b/docs/integration-tests-architecture.md index 8b853c0..b46f269 100644 --- a/docs/integration-tests-architecture.md +++ b/docs/integration-tests-architecture.md @@ -158,11 +158,11 @@ inside the VM; the host only builds the binary and drives Vagrant over WinRM. ``` vm/ - self-test/install.toml Legacy smoke test (HKCU + LocalAppData) - uac/install.toml ProgramFiles target → forces elevation probe - hklm-registry/install.toml HKLM registry key → forces elevation via root - reboot/install.toml Payload + script that self-locks file - bundled-exec/install.toml Packaged installer bundle (HKCU + LocalAppData) + self-test/CovenantSetupSelfTest-install.toml Legacy smoke test (HKCU + LocalAppData) + uac/CovenantSetupUACScenario-install.toml ProgramFiles target → forces elevation probe + hklm-registry/CovenantSetupHKLMRegistryScenario-install.toml HKLM registry key → forces elevation via root + reboot/CovenantSetupRebootScenario-install.toml Payload + script that self-locks file + bundled-exec/CovenantSetupBundledExecScenario-install.toml Packaged installer bundle (HKCU + LocalAppData) scripts/ run-windows-vm-coverage.ps1 Host-side orchestrator diff --git a/docs/vagrant-smoke-debugging-notes.md b/docs/vagrant-smoke-debugging-notes.md index da8172c..503174c 100644 --- a/docs/vagrant-smoke-debugging-notes.md +++ b/docs/vagrant-smoke-debugging-notes.md @@ -105,7 +105,7 @@ This statically links the MSVC C runtime into the Rust executable, removing the Manual PowerShell invocations of the Windows-subsystem Rust executable were misleading. A direct command such as: ```powershell -target\release\covenant-setup.exe --json package vm\self-test\install.toml --output dist\vagrant-self-test +target\release\covenant-setup.exe --json package vm\self-test\CovenantSetupSelfTest-install.toml --output dist\vagrant-self-test ``` could return quickly with `EXIT=0` while the output file still matched the base executable size. diff --git a/scripts/run-windows-vm-coverage.ps1 b/scripts/run-windows-vm-coverage.ps1 index 17e380e..ca17d2d 100644 --- a/scripts/run-windows-vm-coverage.ps1 +++ b/scripts/run-windows-vm-coverage.ps1 @@ -16,8 +16,8 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # Coverage-harness orchestrator. Drives the Vagrant Windows VM through every -# scenario directory under vm\\install.toml using the per-scenario -# guest scripts under scripts\windows-vm\coverage\.ps1. +# scenario directory under vm\\-install.toml using the +# per-scenario guest scripts under scripts\windows-vm\coverage\.ps1. # # All install/uninstall side effects happen INSIDE the VM. The host only: # 1. Builds covenant-setup.exe (release). @@ -73,6 +73,21 @@ function Open-HyperVViewer { Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null } +# The engine enforces the -install.toml naming convention, so each +# scenario directory carries its own manifest file name; discover it instead +# of hardcoding install.toml. +function Get-ScenarioManifest { + param([Parameter(Mandatory)][string]$ScenarioDir) + $candidates = @(Get-ChildItem -LiteralPath $ScenarioDir -Filter "*install.toml" -File | Sort-Object Name) + if ($candidates.Count -eq 0) { + throw "Scenario manifest (*install.toml) not found in: $ScenarioDir" + } + if ($candidates.Count -gt 1) { + throw "Multiple *install.toml manifests found in: $ScenarioDir" + } + return $candidates[0] +} + $repoRoot = Split-Path -Parent $PSScriptRoot $releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" $outputRoot = Join-Path $repoRoot "dist\vagrant-coverage" @@ -98,10 +113,7 @@ if (-not (Test-Path -LiteralPath $releaseExe)) { # Stage the payload tree for each scenario manifest that references # payload\covenant-setup.exe (relative to the manifest dir). foreach ($scenario in $Scenarios) { - $manifest = Join-Path $repoRoot "vm\$scenario\install.toml" - if (-not (Test-Path -LiteralPath $manifest)) { - throw "Scenario manifest not found: $manifest" - } + $manifest = (Get-ScenarioManifest -ScenarioDir (Join-Path $repoRoot "vm\$scenario")).FullName $payloadDir = Join-Path $repoRoot "vm\$scenario\payload" $manifestText = Get-Content -LiteralPath $manifest -Raw if ($manifestText -match 'payload\\\\covenant-setup\.exe' -or $manifestText -match 'payload[\\/]covenant-setup\.exe') { @@ -122,7 +134,16 @@ try { Open-HyperVViewer -VmName $VmName } - $waitForShellCommand = "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }; Write-Error 'Explorer shell did not start in time.'; exit 1" + $waitForShellCommand = @( + "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }" + "`$winlogon = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'" + "Write-Output ('consoleUser=' + (Get-CimInstance Win32_ComputerSystem).UserName)" + "Write-Output ('computerName=' + `$env:COMPUTERNAME)" + "foreach (`$name in 'AutoAdminLogon','ForceAutoLogon','DefaultUserName','DefaultDomainName','AutoLogonCount') { `$value = `$winlogon.PSObject.Properties[`$name]; Write-Output (`$name + '=' + `$(if (`$value) { `$value.Value } else { '' })) }" + "Write-Output ('DefaultPassword=' + `$(if (`$winlogon.PSObject.Properties['DefaultPassword']) { '' } else { '' }))" + "Write-Error 'Explorer shell did not start in time. Auto-logon state is printed above.'" + "exit 1" + ) -join "; " Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) # Prepare guest layout. @@ -156,7 +177,8 @@ try { Write-Host "" Write-Host "[coverage] -> $scenario" -ForegroundColor Cyan $remoteScript = Join-Path $guestScriptRoot "$scenario.ps1" - $remoteManifest = Join-Path $guestScenarioRoot "$scenario\install.toml" + $manifestName = (Get-ScenarioManifest -ScenarioDir (Join-Path $repoRoot "vm\$scenario")).Name + $remoteManifest = Join-Path $guestScenarioRoot "$scenario\$manifestName" $remoteWork = Join-Path $guestWorkRoot $scenario $logRel = "$scenario\guest.log" $remoteLog = Join-Path $guestWorkRoot $logRel diff --git a/scripts/run-windows-vm-smoke.ps1 b/scripts/run-windows-vm-smoke.ps1 index 9f6c7a5..5c2a992 100644 --- a/scripts/run-windows-vm-smoke.ps1 +++ b/scripts/run-windows-vm-smoke.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string]$Provider = "hyperv", - [string]$ManifestPath = "vm\self-test\install.toml", + [string]$ManifestPath = "vm\self-test\CovenantSetupSelfTest-install.toml", [string]$OutputRoot = "dist\vagrant-self-test", [string]$VmName = $(if ($env:COVENANT_VM_NAME) { $env:COVENANT_VM_NAME } else { "covenant-setup-windows" }), [string]$GuestUsername = $(if ($env:COVENANT_WINRM_USERNAME) { $env:COVENANT_WINRM_USERNAME } else { "vagrant" }), @@ -244,7 +244,16 @@ try { Open-HyperVViewer -VmName $VmName } - $waitForShellCommand = "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }; Write-Error 'Explorer shell did not start in time.'; exit 1" + $waitForShellCommand = @( + "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }" + "`$winlogon = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'" + "Write-Output ('consoleUser=' + (Get-CimInstance Win32_ComputerSystem).UserName)" + "Write-Output ('computerName=' + `$env:COMPUTERNAME)" + "foreach (`$name in 'AutoAdminLogon','ForceAutoLogon','DefaultUserName','DefaultDomainName','AutoLogonCount') { `$value = `$winlogon.PSObject.Properties[`$name]; Write-Output (`$name + '=' + `$(if (`$value) { `$value.Value } else { '' })) }" + "Write-Output ('DefaultPassword=' + `$(if (`$winlogon.PSObject.Properties['DefaultPassword']) { '' } else { '' }))" + "Write-Error 'Explorer shell did not start in time. Auto-logon state is printed above.'" + "exit 1" + ) -join "; " Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null") @@ -257,6 +266,38 @@ try { ) -join "; " Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $clearGuestTraceCommand) Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath) + + # Zip the ui folder, excluding bin/obj directories to keep it small + $uiZipPath = Join-Path $outputRootAbs "ui.zip" + Remove-Item -LiteralPath $uiZipPath -Force -ErrorAction SilentlyContinue + + $zipScript = @( + "& {" + " `$uiPath = '$repoRoot\ui'" + " `$zipPath = '$uiZipPath'" + " `$tempDir = Join-Path '$outputRootAbs' 'ui-temp'" + " Remove-Item -LiteralPath `$tempDir -Recurse -Force -ErrorAction SilentlyContinue" + " New-Item -ItemType Directory -Force -Path `$tempDir | Out-Null" + " Get-ChildItem -Path `$uiPath -Recurse | Where-Object { `$_.FullName -notmatch '\\(bin|obj|TestResults|\.vs)($|\\)' } | ForEach-Object {" + " `$relative = `$_.FullName.Substring(`$uiPath.Length + 1)" + " `$target = Join-Path `$tempDir `$relative" + " if (`$_.PSIsContainer) {" + " New-Item -ItemType Directory -Force -Path `$target | Out-Null" + " } else {" + " `$targetParent = Split-Path -Parent `$target" + " New-Item -ItemType Directory -Force -Path `$targetParent -ErrorAction SilentlyContinue | Out-Null" + " Copy-Item -LiteralPath `$_.FullName -Destination `$target -Force" + " }" + " }" + " Compress-Archive -Path (Join-Path `$tempDir '*') -DestinationPath `$zipPath -Force" + " Remove-Item -LiteralPath `$tempDir -Recurse -Force -ErrorAction SilentlyContinue" + "}" + ) -join "`n" + powershell.exe -NoProfile -Command $zipScript + + Invoke-Vagrant -Arguments @("upload", $uiZipPath, "$guestRoot\ui.zip") + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "if (Test-Path -LiteralPath '$guestRoot\ui') { Remove-Item -LiteralPath '$guestRoot\ui' -Recurse -Force -ErrorAction SilentlyContinue }; Expand-Archive -Path '$guestRoot\ui.zip' -DestinationPath '$guestRoot\ui' -Force") + Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1")) Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1")) diff --git a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 index bad0065..9c1a119 100644 --- a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 +++ b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 @@ -129,51 +129,95 @@ try { $env:COVENANT_SETUP_TRACE_DIR = $TracePath Write-TraceEvent -Phase "trace_environment_set" -Detail @{ name = "COVENANT_SETUP_TRACE_DIR"; value = $TracePath } - $installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru - Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName } - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - $lastPoll = Get-Date "2000-01-01" - while (-not $installer.HasExited) { - if ((Get-Date) -ge $deadline) { - Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer - Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue - throw "Installer timed out after $TimeoutSeconds seconds." + $testProjectDir = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\ui\Covenant.Setup.Ui.Tests" + if (Test-Path -LiteralPath "$testProjectDir\Covenant.Setup.Ui.Tests.csproj") { + $env:COVENANT_REAL_INSTALLER_PATH = $InstallerPath + $env:COVENANT_REAL_INSTALLER_ARGS = $InstallerArguments -join ' ' + Write-TraceEvent -Phase "running_ui_automation_tests" -Detail @{ + installerPath = $InstallerPath + arguments = $InstallerArguments } + + $dotnetPath = "C:\Users\vagrant\dotnet\dotnet.exe" + if (-not (Test-Path -LiteralPath $dotnetPath)) { + $dotnetPath = "dotnet" + } + + Write-TraceEvent -Phase "executing_dotnet_test" -Detail @{ + dotnetPath = $dotnetPath + project = "$testProjectDir\Covenant.Setup.Ui.Tests.csproj" + } + + $stdoutLog = Join-Path $TracePath "dotnet-test.log" + $stderrLog = Join-Path $TracePath "dotnet-test-error.log" + + $process = Start-Process -FilePath $dotnetPath -ArgumentList @("test", "$testProjectDir\Covenant.Setup.Ui.Tests.csproj", "--filter", "FullyQualifiedName=Covenant.Setup.Ui.Tests.InstallerUiAutomationTests.TestRealInstallUninstallFlow") -PassThru -NoNewWindow -Wait -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog + + Write-TraceEvent -Phase "dotnet_test_finished" -Detail @{ exitCode = $process.ExitCode } + + $result = [ordered]@{ + success = ($process.ExitCode -eq 0) + exitCode = [int]$process.ExitCode + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + tracePath = $TracePath + startedAt = $startedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + } + $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + + if ($process.ExitCode -ne 0) { + exit $process.ExitCode + } + } + else { + $installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru + Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName } - if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { - $lastPoll = Get-Date - $installer.Refresh() - Write-TraceEvent -Phase "installer_still_running" -Detail @{ - pid = $installer.Id - operationName = $OperationName - elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds - responding = $installer.Responding - mainWindow = $installer.MainWindowTitle + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPoll = Get-Date "2000-01-01" + while (-not $installer.HasExited) { + if ((Get-Date) -ge $deadline) { + Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer + Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue + throw "Installer timed out after $TimeoutSeconds seconds." } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { + $lastPoll = Get-Date + $installer.Refresh() + Write-TraceEvent -Phase "installer_still_running" -Detail @{ + pid = $installer.Id + operationName = $OperationName + elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds + responding = $installer.Responding + mainWindow = $installer.MainWindowTitle + } + } + + Start-Sleep -Seconds 2 + $installer.Refresh() } - Start-Sleep -Seconds 2 - $installer.Refresh() - } + Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName } + Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer - Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName } - Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer + $result = [ordered]@{ + success = ($installer.ExitCode -eq 0) + exitCode = [int]$installer.ExitCode + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + tracePath = $TracePath + startedAt = $startedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + } + $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 - $result = [ordered]@{ - success = ($installer.ExitCode -eq 0) - exitCode = [int]$installer.ExitCode - installerPath = $InstallerPath - installerArgs = $InstallerArguments - operationName = $OperationName - tracePath = $TracePath - startedAt = $startedAt.ToString("o") - finishedAt = (Get-Date).ToString("o") - } - $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 - - if ($installer.ExitCode -ne 0) { - exit $installer.ExitCode + if ($installer.ExitCode -ne 0) { + exit $installer.ExitCode + } } } catch { diff --git a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 index f265d73..08657c1 100644 --- a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 +++ b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 @@ -321,6 +321,43 @@ try { timeoutSeconds = $TimeoutSeconds } + # Install VC++ Redistributable if missing + $vcredistInstalled = $false + $vcredistKey = "HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" + if (Test-Path -Path $vcredistKey) { + $vcredistVal = Get-ItemProperty -Path $vcredistKey -Name "Installed" -ErrorAction SilentlyContinue + if ($vcredistVal -and $vcredistVal.Installed -eq 1) { + $vcredistInstalled = $true + } + } + + if (-not $vcredistInstalled) { + Write-TraceEvent -Phase "vcredist_install_start" + try { + $vcredistPath = Join-Path $env:TEMP "vc_redist.x64.exe" + Remove-Item -Path $vcredistPath -Force -ErrorAction SilentlyContinue + Write-Host "Downloading VC++ Redistributable..." + $oldProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile $vcredistPath + $ProgressPreference = $oldProgress + + Write-Host "Installing VC++ Redistributable..." + $proc = Start-Process -FilePath $vcredistPath -ArgumentList "/install", "/quiet", "/norestart" -PassThru -Wait + if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) { # 3010 is success but reboot required + throw "vc_redist installation failed with exit code $($proc.ExitCode)" + } + Write-TraceEvent -Phase "vcredist_install_success" -Detail @{ exitCode = $proc.ExitCode } + } + catch { + Write-TraceEvent -Phase "vcredist_install_error" -Detail @{ error = $_.Exception.Message } + Write-Warning "Failed to install VC++ Redistributable: $($_.Exception.Message)" + } + } + else { + Write-TraceEvent -Phase "vcredist_already_installed" + } + if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found in guest: $InstallerPath" } diff --git a/src/main.rs b/src/main.rs index a27e64c..fbac469 100644 --- a/src/main.rs +++ b/src/main.rs @@ -446,7 +446,7 @@ fn main() { && !preferences.json && sys.ui_available() && !failure_ux_shown() - && !matches!(err, AppError::Cancelled) + && !matches!(err, AppError::Cancelled | AppError::CancelledByUser) { let _ = sys.ui_report_error(&err.to_string()); } @@ -472,7 +472,7 @@ fn main() { fn error_exit_code(err: &AppError) -> i32 { match err { - AppError::Cancelled => EXIT_CANCELLED, + AppError::Cancelled | AppError::CancelledByUser => EXIT_CANCELLED, AppError::Message(message) if message.contains("Elevation required") => { EXIT_ELEVATION_REQUIRED } @@ -526,6 +526,7 @@ fn run(cli: Cli, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { } fn package(manifest_path: &Path, output_root: &Path, logger: &Logger) -> Result<(), AppError> { + enforce_manifest_path_spacing(manifest_path)?; let manifest = read_install_manifest(manifest_path)?; let current_exe = std::env::current_exe()?; let manifest_dir = manifest_path @@ -558,10 +559,10 @@ fn read_install_manifest(manifest_path: &Path) -> Result Result<(), AppError> { +/// Packaging-time rule only: install must accept manifests from any +/// directory because bundled installs extract under %TEMP%, which contains +/// spaces for user profiles like `C:\Users\First Last`. +fn enforce_manifest_path_spacing(manifest_path: &Path) -> Result<(), AppError> { let manifest_path_text = manifest_path.to_string_lossy(); if contains_whitespace(&manifest_path_text) { return Err(AppError::Message(format!( @@ -570,6 +571,13 @@ fn enforce_manifest_file_name( ))); } + Ok(()) +} + +fn enforce_manifest_file_name( + manifest_path: &Path, + manifest: &InstallManifest, +) -> Result<(), AppError> { let expected = expected_manifest_file_name(&manifest.app_name); let actual = manifest_path .file_name() @@ -1013,14 +1021,17 @@ fn install( progress_override } else { let install_root = infer_install_root(&manifest, &resolver); + let branding_image = find_branding_image(manifest_path.parent(), install_root.as_deref()); + let show_welcome = !automation && !manifest.app_name.trim().is_empty(); start_gui_progress( ui_mode, sys, &format!("Installing {}", manifest.app_name), Some(&manifest.app_name), install_root.as_deref(), + branding_image.as_deref(), + show_welcome, install_total, - automation, &effective_logger, )? }; @@ -1361,8 +1372,9 @@ fn uninstall( None }, None, + None, + false, uninstall_total, - automation, logger, )?; } @@ -1690,6 +1702,18 @@ fn infer_install_root(manifest: &InstallManifest, resolver: &win::PathResolver) None } +/// Branding ships with the payload, so the manifest directory is probed +/// first; the install root only exists on reinstalls over a previous tree. +fn find_branding_image(manifest_dir: Option<&Path>, install_root: Option<&Path>) -> Option { + const BRANDING_FILE_NAMES: [&str; 3] = ["branding.png", "banner.png", "logo.png"]; + manifest_dir + .into_iter() + .chain(install_root) + .flat_map(|root| BRANDING_FILE_NAMES.iter().map(move |name| root.join(name))) + .find(|path| path.is_file()) + .map(|path| path.to_string_lossy().into_owned()) +} + fn install_uninstaller(uninstall_exe_path: &Path, logger: &Logger) -> Result<(), AppError> { if let Some(parent) = uninstall_exe_path.parent() { fs::create_dir_all(parent)?; @@ -1853,8 +1877,9 @@ fn start_gui_progress( title: &str, app_name: Option<&str>, install_root: Option<&Path>, + branding_image: Option<&str>, + show_welcome: bool, total_steps: usize, - automation: bool, logger: &Logger, ) -> Result>, AppError> { trace_event( @@ -1862,7 +1887,8 @@ fn start_gui_progress( json!({ "ui_mode": ui_mode_name(ui_mode), "title": title, - "total_steps": total_steps.max(1) + "total_steps": total_steps.max(1), + "show_welcome": show_welcome }), ); if let Some(sink) = sys.start_progress(ui_mode, title, total_steps.max(1))? { @@ -1875,8 +1901,9 @@ fn start_gui_progress( install_root .map(|p| p.to_string_lossy().into_owned()) .as_deref(), + branding_image, + show_welcome, total_steps.max(1), - automation, logger, )?))) } else { @@ -2805,15 +2832,68 @@ path = '{LocalAppData}\Legacy\bin' .to_string(); assert!(err.contains("SampleApp-install.toml")); - let err = enforce_manifest_file_name( + // Only the file name is constrained at install time; the directory may + // contain spaces (bundled installs extract under %TEMP%). + enforce_manifest_file_name( Path::new("manifest folder\\SampleApp-install.toml"), &manifest, ) - .unwrap_err() - .to_string(); + .unwrap(); + } + + #[test] + fn manifest_path_spacing_is_enforced_only_for_packaging() { + enforce_manifest_path_spacing(Path::new("C:\\work\\SampleApp-install.toml")).unwrap(); + + let err = + enforce_manifest_path_spacing(Path::new("manifest folder\\SampleApp-install.toml")) + .unwrap_err() + .to_string(); assert!(err.contains("cannot contain spaces")); } + #[test] + fn read_install_manifest_accepts_spaces_in_parent_directory() { + let temp = TestDir::new("manifest-spaced-parent"); + let spaced_dir = temp.path().join("User Name"); + fs::create_dir_all(&spaced_dir).unwrap(); + let manifest_path = spaced_dir.join("SpacedDir-install.toml"); + fs::write(&manifest_path, "app_name = 'Spaced Dir'\n").unwrap(); + + read_install_manifest(&manifest_path).unwrap(); + } + + #[test] + fn find_branding_image_prefers_manifest_dir_over_install_root() { + let temp = TestDir::new("branding-image"); + let manifest_dir = temp.path().join("payload-src"); + let install_root = temp.path().join("install-root"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::create_dir_all(&install_root).unwrap(); + + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + None + ); + + fs::write(install_root.join("logo.png"), b"png").unwrap(); + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + Some(install_root.join("logo.png").to_string_lossy().into_owned()) + ); + + fs::write(manifest_dir.join("branding.png"), b"png").unwrap(); + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + Some( + manifest_dir + .join("branding.png") + .to_string_lossy() + .into_owned() + ) + ); + } + #[test] fn manifest_field_spacing_allows_only_app_name_and_description_spaces() { let mut manifest = InstallManifest { @@ -3055,8 +3135,9 @@ description = 'Description can have spaces' "No UI", None, None, - 0, + None, false, + 0, &quiet_logger() ) .unwrap() @@ -4342,6 +4423,7 @@ description = 'Description can have spaces' #[test] fn error_exit_code_maps_cancelled_elevation_and_default() { assert_eq!(error_exit_code(&AppError::Cancelled), EXIT_CANCELLED); + assert_eq!(error_exit_code(&AppError::CancelledByUser), EXIT_CANCELLED); assert_eq!( error_exit_code(&AppError::Message( "Elevation required to write to HKLM".into() diff --git a/src/ui.rs b/src/ui.rs index 0472da2..456aa0c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,7 @@ use crate::{AppError, Logger, win}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::collections::VecDeque; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Read, Write}; use std::os::windows::process::CommandExt; @@ -116,23 +117,13 @@ impl GuiProgress { title: &str, app_name: Option<&str>, install_root: Option<&str>, + branding_image: Option<&str>, + show_welcome: bool, total_steps: usize, - automation: bool, logger: &Logger, ) -> Result { let mut session = CSharpUiSession::start()?; - let branding_image = if let (Some(_), Some(root_path)) = (app_name, install_root) { - let root = Path::new(root_path); - ["branding.png", "banner.png", "logo.png"] - .iter() - .map(|name| root.join(name)) - .find(|path| path.is_file()) - .map(|path| path.to_string_lossy().into_owned()) - } else { - None - }; - session.send(&json!({ "type": "init", "title": title, @@ -140,7 +131,7 @@ impl GuiProgress { "install_dir": install_root, "branding_image": branding_image, "total_steps": total_steps.max(1), - "automation": automation, + "show_welcome": show_welcome, }))?; #[derive(Deserialize)] @@ -150,7 +141,11 @@ impl GuiProgress { result: Option, } - if app_name.is_some() && install_root.is_some() && !automation { + // The explicit show_welcome flag is the single source of truth for + // the handshake: the UI shows the welcome page and replies exactly + // when the init message carries it, so the two sides cannot disagree + // about whether a welcome_response is owed. + if show_welcome { let response: WelcomeResponse = session.read()?; if response.message_type != "welcome_response" || response.result.as_deref() != Some("install") @@ -343,7 +338,10 @@ struct CSharpUiSession { child: Child, reader: BufReader, writer: fs::File, + /// Raw bytes of a partial line pulled in by a non-blocking drain. pending_input: Vec, + /// Complete lines drained while polling that no consumer has read yet. + queued_lines: VecDeque, cleanup_root: Option, closed: bool, child_exited: bool, @@ -376,6 +374,7 @@ impl CSharpUiSession { reader: BufReader::new(pipe), writer, pending_input: Vec::new(), + queued_lines: VecDeque::new(), cleanup_root, closed: false, child_exited: false, @@ -393,11 +392,20 @@ impl CSharpUiSession { } fn read Deserialize<'de>>(&mut self) -> Result { - let mut line = String::new(); - let bytes = self.reader.read_line(&mut line)?; - if bytes == 0 { - return Err(AppError::Message("UI pipe closed before response".into())); - } + let line = match self.queued_lines.pop_front() { + Some(line) => line, + None => { + // Resume any partial line a non-blocking drain left behind + // before falling back to a blocking read. + let mut line = String::from_utf8_lossy(&self.pending_input).into_owned(); + self.pending_input.clear(); + let bytes = self.reader.read_line(&mut line)?; + if bytes == 0 { + return Err(AppError::Message("UI pipe closed before response".into())); + } + line + } + }; let value: Value = serde_json::from_str(&line)?; crate::trace_event("ui_pipe_receive", message_summary(&value)); Ok(serde_json::from_value(value)?) @@ -414,17 +422,17 @@ impl CSharpUiSession { Ok(()) } - /// Drains any complete UI->engine lines and reports whether one was a - /// `cancel_request`. Never blocks: bytes are only pulled after - /// PeekNamedPipe says they are available, because a read left pending on - /// this synchronous pipe handle would serialize against (and stall) the - /// engine's progress writes on the same handle. - fn poll_cancel_request(&mut self, logger: &Logger) -> bool { - let mut saw_cancel = false; + /// Pulls every byte currently available on the pipe without blocking and + /// queues complete lines for `read` or `poll_cancel_request` to consume. + /// Bytes are only pulled after PeekNamedPipe says they are available, + /// because a read left pending on this synchronous pipe handle would + /// serialize against (and stall) the engine's progress writes on the + /// same handle. + fn drain_available_input(&mut self, logger: &Logger) { loop { let buffered = self.reader.buffer().len(); if buffered > 0 { - // Leftover bytes the welcome-handshake read pulled in. + // Leftover bytes a previous blocking read pulled in. let chunk = self.reader.buffer().to_vec(); self.reader.consume(buffered); self.pending_input.extend_from_slice(&chunk); @@ -443,15 +451,28 @@ impl CSharpUiSession { while let Some(newline) = self.pending_input.iter().position(|byte| *byte == b'\n') { let line: Vec = self.pending_input.drain(..=newline).collect(); - let line = String::from_utf8_lossy(&line); - if let Ok(value) = serde_json::from_str::(&line) - && value.get("type").and_then(Value::as_str) == Some("cancel_request") - { - crate::trace_event("ui_cancel_request", json!({})); - saw_cancel = true; - } + self.queued_lines + .push_back(String::from_utf8_lossy(&line).into_owned()); } } + } + + /// Reports whether the UI sent a `cancel_request`. Only cancel lines are + /// consumed; every other UI->engine line stays queued for the next + /// blocking `read` instead of being dropped. + fn poll_cancel_request(&mut self, logger: &Logger) -> bool { + self.drain_available_input(logger); + let mut saw_cancel = false; + self.queued_lines.retain(|line| { + let is_cancel = serde_json::from_str::(line).is_ok_and(|value| { + value.get("type").and_then(Value::as_str) == Some("cancel_request") + }); + if is_cancel { + crate::trace_event("ui_cancel_request", json!({})); + saw_cancel = true; + } + !is_cancel + }); saw_cancel } } diff --git a/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj b/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj index 4278d11..e045900 100644 --- a/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj +++ b/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/ui/Covenant.Setup.Ui.Tests/InstallerSessionControllerTests.cs b/ui/Covenant.Setup.Ui.Tests/InstallerSessionControllerTests.cs index 9386b29..8790e07 100644 --- a/ui/Covenant.Setup.Ui.Tests/InstallerSessionControllerTests.cs +++ b/ui/Covenant.Setup.Ui.Tests/InstallerSessionControllerTests.cs @@ -236,14 +236,14 @@ public class InstallerSessionControllerTests } [Fact] - public void HandleMessage_init_with_appName_and_installDir_shows_welcome_and_proceeds_on_install() + public void HandleMessage_init_with_show_welcome_shows_welcome_and_proceeds_on_install() { var view = new FakeInstallerView { WelcomeResponder = (app, dir, img) => "install" }; var controller = new InstallerSessionController("dummy", view); using var sw = new StringWriter(); controller.ResponseWriter = sw; - var ok = controller.HandleMessage("{\"type\":\"init\",\"title\":\"My Title\",\"app_name\":\"TestApp\",\"install_dir\":\"C:\\\\Test\",\"branding_image\":\"img.png\"}"); + var ok = controller.HandleMessage("{\"type\":\"init\",\"show_welcome\":true,\"title\":\"My Title\",\"app_name\":\"TestApp\",\"install_dir\":\"C:\\\\Test\",\"branding_image\":\"img.png\"}"); Assert.True(ok); Assert.Equal(2, view.Calls.Count); @@ -258,14 +258,14 @@ public class InstallerSessionControllerTests } [Fact] - public void HandleMessage_init_with_appName_and_installDir_shows_welcome_and_cancels() + public void HandleMessage_init_with_show_welcome_shows_welcome_and_cancels() { var view = new FakeInstallerView { WelcomeResponder = (app, dir, img) => "cancel" }; var controller = new InstallerSessionController("dummy", view); using var sw = new StringWriter(); controller.ResponseWriter = sw; - var ok = controller.HandleMessage("{\"type\":\"init\",\"title\":\"My Title\",\"app_name\":\"TestApp\",\"install_dir\":\"C:\\\\Test\",\"branding_image\":\"img.png\"}"); + var ok = controller.HandleMessage("{\"type\":\"init\",\"show_welcome\":true,\"title\":\"My Title\",\"app_name\":\"TestApp\",\"install_dir\":\"C:\\\\Test\",\"branding_image\":\"img.png\"}"); Assert.False(ok); Assert.Equal(2, view.Calls.Count); @@ -278,4 +278,39 @@ public class InstallerSessionControllerTests Assert.Equal("welcome_response", root.GetProperty("type").GetString()); Assert.Equal("cancel", root.GetProperty("result").GetString()); } + + [Fact] + public void HandleMessage_init_without_show_welcome_skips_welcome() + { + var view = new FakeInstallerView(); + var controller = new InstallerSessionController("dummy", view); + using var sw = new StringWriter(); + controller.ResponseWriter = sw; + + var ok = controller.HandleMessage("{\"type\":\"init\",\"title\":\"My Title\",\"app_name\":\"TestApp\",\"install_dir\":\"C:\\\\Test\"}"); + + Assert.True(ok); + Assert.Single(view.Calls); + Assert.Equal("ShowInit: title=My Title, message=My Title", view.Calls[0]); + Assert.Equal(string.Empty, sw.ToString()); + } + + [Fact] + public void HandleMessage_init_with_show_welcome_but_no_install_dir_still_prompts() + { + var view = new FakeInstallerView { WelcomeResponder = (app, dir, img) => "install" }; + var controller = new InstallerSessionController("dummy", view); + using var sw = new StringWriter(); + controller.ResponseWriter = sw; + + var ok = controller.HandleMessage("{\"type\":\"init\",\"show_welcome\":true,\"title\":\"My Title\",\"app_name\":\"TestApp\"}"); + + Assert.True(ok); + Assert.Equal(2, view.Calls.Count); + Assert.Equal("ShowWelcomeAsync: appName=TestApp, installDir=, brandingImage=", view.Calls[0]); + + var written = sw.ToString().Trim(); + using var responseDoc = JsonDocument.Parse(written); + Assert.Equal("welcome_response", responseDoc.RootElement.GetProperty("type").GetString()); + } } diff --git a/ui/Covenant.Setup.Ui.Tests/InstallerUiAutomationTests.cs b/ui/Covenant.Setup.Ui.Tests/InstallerUiAutomationTests.cs new file mode 100644 index 0000000..9d33fdb --- /dev/null +++ b/ui/Covenant.Setup.Ui.Tests/InstallerUiAutomationTests.cs @@ -0,0 +1,536 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.UIA3; +using Xunit; + +namespace Covenant.Setup.Ui.Tests; + +public class InstallerUiAutomationTests +{ + private string GetExePath() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (Directory.Exists(Path.Combine(dir, "Covenant.Setup.Ui")) && + Directory.Exists(Path.Combine(dir, "Covenant.Setup.Ui.Tests"))) + { + break; + } + dir = Path.GetDirectoryName(dir); + } + if (string.IsNullOrEmpty(dir)) + { + throw new DirectoryNotFoundException("Could not find ui root directory"); + } + var uiDir = dir; + var exePath = Path.Combine(uiDir, "Covenant.Setup.Ui", "bin", "x64", "Debug", "net10.0-windows10.0.19041.0", "win-x64", "Covenant.Setup.Ui.exe"); + if (!File.Exists(exePath)) + { + exePath = Path.Combine(uiDir, "Covenant.Setup.Ui", "bin", "x64", "Release", "net10.0-windows10.0.19041.0", "win-x64", "Covenant.Setup.Ui.exe"); + } + return exePath; + } + + private static string DumpDescendants(AutomationElement root) + { + var sb = new StringBuilder(); + DumpDescendantsInternal(root, sb, 0); + return sb.ToString(); + } + + private static void DumpDescendantsInternal(AutomationElement element, StringBuilder sb, int indent) + { + if (indent > 8) return; + var indentStr = new string(' ', indent * 2); + try + { + sb.AppendLine($"{indentStr}- Name: '{element.Name}', Id: '{element.AutomationId}', Class: '{element.ClassName}'"); + foreach (var child in element.FindAllChildren()) + { + DumpDescendantsInternal(child, sb, indent + 1); + } + } + catch (System.Exception ex) + { + sb.AppendLine($"{indentStr}- [Error reading element: {ex.Message}]"); + } + } + + private static AutomationElement FindElement(Window window, string automationId, int timeoutMs = 10000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + var el = window.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (el != null) + { + return el; + } + Thread.Sleep(100); + } + throw new Exception($"Failed to find element with AutomationId '{automationId}' after {timeoutMs}ms.\nVisual Tree:\n{DumpDescendants(window)}"); + } + + private static AutomationElement FindElementByName(Window window, string name, int timeoutMs = 10000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + var el = window.FindFirstDescendant(cf => cf.ByName(name)); + if (el != null) + { + return el; + } + Thread.Sleep(100); + } + throw new Exception($"Failed to find element with Name '{name}' after {timeoutMs}ms.\nVisual Tree:\n{DumpDescendants(window)}"); + } + + private static Window GetMainWindow(Application app, AutomationBase automation, string titlePart, int timeoutMs = 15000) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + try + { + var windows = app.GetAllTopLevelWindows(automation); + var target = windows.FirstOrDefault(w => + w.Title.Contains(titlePart, System.StringComparison.OrdinalIgnoreCase) || + w.Title.Contains("covenant-setup", System.StringComparison.OrdinalIgnoreCase) || + w.Title.Contains("SampleApp", System.StringComparison.OrdinalIgnoreCase)); + if (target != null) + { + return target; + } + } + catch + { + // Ignore COM errors + } + + try + { + var desktopChildren = automation.GetDesktop().FindAllChildren(); + foreach (var child in desktopChildren) + { + if (child.ControlType == FlaUI.Core.Definitions.ControlType.Window) + { + var w = child.AsWindow(); + if (w.Title.Contains(titlePart, System.StringComparison.OrdinalIgnoreCase) || + w.Title.Contains("covenant-setup", System.StringComparison.OrdinalIgnoreCase) || + w.Title.Contains("SampleApp", System.StringComparison.OrdinalIgnoreCase) || + w.Title.Contains("Uninstalling", System.StringComparison.OrdinalIgnoreCase)) + { + return w; + } + } + } + } + catch + { + // Ignore COM errors + } + + Thread.Sleep(250); + } + + var allWindowsStr = "None"; + try + { + allWindowsStr = string.Join(", ", automation.GetDesktop().FindAllChildren() + .Where(c => c.ControlType == FlaUI.Core.Definitions.ControlType.Window) + .Select(c => $"'{c.Name}' ({c.ClassName})")); + } + catch {} + throw new Exception($"Failed to find window with title containing '{titlePart}' after {timeoutMs}ms. All desktop windows found: {allWindowsStr}"); + } + + private static void ClickElement(AutomationElement element) + { + if (element is Button button) + { + button.Invoke(); + } + else if (element.Patterns.Invoke.IsSupported) + { + element.Patterns.Invoke.Pattern.Invoke(); + } + else if (element.Patterns.Toggle.IsSupported) + { + element.Patterns.Toggle.Pattern.Toggle(); + } + else if (element.Patterns.SelectionItem.IsSupported) + { + element.Patterns.SelectionItem.Pattern.Select(); + } + else + { + element.Click(false); + } + } + + [Fact] + public void TestMockInstallHappy() + { + var exePath = GetExePath(); + Assert.True(File.Exists(exePath), $"Executable not found at: {exePath}"); + + using (var app = Application.Launch(exePath, "--mock install-happy")) + { + using (var automation = new UIA3Automation()) + { + var window = GetMainWindow(app, automation, "covenant-setup"); + Assert.NotNull(window); + + // Click the Welcome Install button + var installBtn = FindElement(window, "WelcomeInstallButton").AsButton(); + ClickElement(installBtn); + + // Wait for the install process to finish or for the application to exit + var stopwatch = Stopwatch.StartNew(); + bool exitedCleanly = false; + while (stopwatch.ElapsedMilliseconds < 15000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + try + { + var btn = window.FindFirstDescendant(cf => cf.ByAutomationId("CancelButton"))?.AsButton(); + if (btn != null && btn.Name == "Close") + { + ClickElement(btn); + break; + } + } + catch + { + // Ignore UIA/COM exceptions while UI transitions + } + Thread.Sleep(250); + } + + if (!exitedCleanly) + { + stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < 5000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + Thread.Sleep(250); + } + } + Assert.True(exitedCleanly || app.HasExited, "Application should have exited."); + } + } + } + + [Fact] + public void TestMockInstallPrompt() + { + var exePath = GetExePath(); + Assert.True(File.Exists(exePath), $"Executable not found at: {exePath}"); + + using (var app = Application.Launch(exePath, "--mock install-prompt")) + { + using (var automation = new UIA3Automation()) + { + var window = GetMainWindow(app, automation, "covenant-setup"); + Assert.NotNull(window); + + // An OK/Cancel content dialog should prompt FIRST. Accept it. + var okBtn = FindElementByName(window, "OK").AsButton(); + ClickElement(okBtn); + + // Click the Welcome Install button which appears after confirming the prompt + var installBtn = FindElement(window, "WelcomeInstallButton").AsButton(); + ClickElement(installBtn); + + // Wait for the install process to finish or for the application to exit + var stopwatch = Stopwatch.StartNew(); + bool exitedCleanly = false; + while (stopwatch.ElapsedMilliseconds < 15000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + try + { + var btn = window.FindFirstDescendant(cf => cf.ByAutomationId("CancelButton"))?.AsButton(); + if (btn != null && btn.Name == "Close") + { + ClickElement(btn); + break; + } + } + catch + { + // Ignore UIA/COM exceptions while UI transitions + } + Thread.Sleep(250); + } + + if (!exitedCleanly) + { + stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < 5000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + Thread.Sleep(250); + } + } + Assert.True(exitedCleanly || app.HasExited, "Application should have exited."); + } + } + } + + [Fact] + public void TestMockInstallFailErrata() + { + var exePath = GetExePath(); + Assert.True(File.Exists(exePath), $"Executable not found at: {exePath}"); + + using (var app = Application.Launch(exePath, "--mock install-fail-errata")) + { + using (var automation = new UIA3Automation()) + { + var window = GetMainWindow(app, automation, "covenant-setup"); + Assert.NotNull(window); + + // Click the Welcome Install button + var installBtn = FindElement(window, "WelcomeInstallButton").AsButton(); + ClickElement(installBtn); + + // Wait for failure. SaveErrataButton and CopyErrorButton should be visible + Button? saveErrataBtn = null; + Button? copyErrorBtn = null; + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < 10000) + { + try + { + var saveBtn = window.FindFirstDescendant(cf => cf.ByAutomationId("SaveErrataButton"))?.AsButton(); + var copyBtn = window.FindFirstDescendant(cf => cf.ByAutomationId("CopyErrorButton"))?.AsButton(); + if (saveBtn != null && copyBtn != null && !saveBtn.IsOffscreen && !copyBtn.IsOffscreen) + { + saveErrataBtn = saveBtn; + copyErrorBtn = copyBtn; + break; + } + } + catch + { + // Ignore COM exceptions during transition + } + Thread.Sleep(250); + } + + Assert.NotNull(saveErrataBtn); + Assert.NotNull(copyErrorBtn); + Assert.True(saveErrataBtn.IsEnabled, "Save Errata button should be enabled on failure."); + Assert.True(copyErrorBtn.IsEnabled, "Copy Error button should be enabled on failure."); + + // Close the installer manually since it does not auto-exit on failure + var closeBtn = FindElement(window, "CancelButton").AsButton(); + ClickElement(closeBtn); + } + } + } + + [Fact] + public void TestMockUninstallRebootPrompt() + { + var exePath = GetExePath(); + Assert.True(File.Exists(exePath), $"Executable not found at: {exePath}"); + + using (var app = Application.Launch(exePath, "--mock uninstall-reboot-prompt")) + { + using (var automation = new UIA3Automation()) + { + var window = GetMainWindow(app, automation, "Uninstalling"); + Assert.NotNull(window); + + // At the end, a Yes/No restart prompt will show up. Choose No. + var noBtn = FindElementByName(window, "No").AsButton(); + ClickElement(noBtn); + + // Wait for the uninstall process to finish or for the application to exit + var stopwatch = Stopwatch.StartNew(); + bool exitedCleanly = false; + while (stopwatch.ElapsedMilliseconds < 15000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + try + { + var btn = window.FindFirstDescendant(cf => cf.ByAutomationId("CancelButton"))?.AsButton(); + if (btn != null && btn.Name == "Close") + { + ClickElement(btn); + break; + } + } + catch + { + // Ignore UIA/COM exceptions during transition + } + Thread.Sleep(250); + } + + if (!exitedCleanly) + { + stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < 5000) + { + if (app.HasExited) + { + exitedCleanly = true; + break; + } + Thread.Sleep(250); + } + } + Assert.True(exitedCleanly || app.HasExited, "Application should have exited."); + } + } + } + + [Fact] + public void TestRealInstallUninstallFlow() + { + // This test runs only when configured via environment variables + var realInstallerPath = System.Environment.GetEnvironmentVariable("COVENANT_REAL_INSTALLER_PATH"); + if (string.IsNullOrEmpty(realInstallerPath) || !File.Exists(realInstallerPath)) + { + return; // Skip if not executing in the VM real context + } + + var realInstallerArgs = System.Environment.GetEnvironmentVariable("COVENANT_REAL_INSTALLER_ARGS") ?? "--headed"; + + // Filter out --automation to ensure the installer/uninstaller runs interactively + var argsList = realInstallerArgs.Split(' ', System.StringSplitOptions.RemoveEmptyEntries).ToList(); + bool isUninstall = argsList.Any(a => a.Equals("uninstall", System.StringComparison.OrdinalIgnoreCase)) || + realInstallerPath.Contains("uninstall", System.StringComparison.OrdinalIgnoreCase); + argsList.Remove("--automation"); + realInstallerArgs = string.Join(" ", argsList); + + using (var app = Application.Launch(realInstallerPath, realInstallerArgs)) + { + using (var automation = new UIA3Automation()) + { + if (!isUninstall) + { + // 1. Consent/Welcome Page: Click Install + var window = GetMainWindow(app, automation, "covenant-setup"); + Assert.NotNull(window); + + var installBtn = FindElement(window, "WelcomeInstallButton").AsButton(); + ClickElement(installBtn); + + // 2. Wait for progress to finish and close button to appear + Button? closeBtn = null; + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < 30000) + { + try + { + var btn = window.FindFirstDescendant(cf => cf.ByAutomationId("CancelButton"))?.AsButton(); + if (btn != null && btn.Name == "Close") + { + closeBtn = btn; + break; + } + } + catch + { + // Ignore COM exceptions during transition + } + Thread.Sleep(250); + } + Assert.NotNull(closeBtn); + Assert.Equal("Close", closeBtn.Name); + ClickElement(closeBtn); + } + else + { + // For uninstall: progress runs immediately. + // When finished, the uninstall success prompt "covenant-setup" shows up. + // Let's wait for that prompt window to appear. + var stopwatch = Stopwatch.StartNew(); + Window? promptWindow = null; + while (stopwatch.ElapsedMilliseconds < 30000) + { + try + { + // Search all desktop windows for one named "covenant-setup" + var desktopChildren = automation.GetDesktop().FindAllChildren(); + foreach (var child in desktopChildren) + { + if (child.ControlType == FlaUI.Core.Definitions.ControlType.Window) + { + var w = child.AsWindow(); + if (w.Title.Equals("covenant-setup", System.StringComparison.OrdinalIgnoreCase)) + { + promptWindow = w; + break; + } + } + } + if (promptWindow != null) + { + break; + } + } + catch + { + // Ignore COM exceptions + } + Thread.Sleep(250); + } + + Assert.NotNull(promptWindow); + + // The dialog has an "OK" button. Find it and click it. + var okBtn = FindElementByName(promptWindow, "OK").AsButton(); + ClickElement(okBtn); + } + + // Verify the application exits cleanly + var exitStopwatch = Stopwatch.StartNew(); + bool exited = false; + while (exitStopwatch.ElapsedMilliseconds < 10000) + { + if (app.HasExited) + { + exited = true; + break; + } + Thread.Sleep(250); + } + Assert.True(exited || app.HasExited, "Application should have exited after closing final dialog/window."); + } + } + } +} diff --git a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj index 7065fb0..4aba10b 100644 --- a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj +++ b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj @@ -5,6 +5,8 @@ true None true + true + true false false true diff --git a/ui/Covenant.Setup.Ui/InstallerSessionController.cs b/ui/Covenant.Setup.Ui/InstallerSessionController.cs index 28118f2..b6ad5ed 100644 --- a/ui/Covenant.Setup.Ui/InstallerSessionController.cs +++ b/ui/Covenant.Setup.Ui/InstallerSessionController.cs @@ -106,10 +106,16 @@ internal sealed class InstallerSessionController(string pipeName, IInstallerView { case "init": var title = message.Title ?? "covenant-setup"; - if (!string.IsNullOrEmpty(message.AppName) && !string.IsNullOrEmpty(message.InstallDir) && message.Automation != true) + // The engine's explicit show_welcome flag is the single source + // of truth for the handshake; it awaits a welcome_response + // exactly when it sent the flag. + if (message.ShowWelcome == true) { UiTrace.Write("welcome_show_requested", new { message.AppName, message.InstallDir }); - var welcomeResult = view.ShowWelcomeAsync(message.AppName, message.InstallDir, message.BrandingImage).GetAwaiter().GetResult(); + var welcomeResult = view.ShowWelcomeAsync( + string.IsNullOrEmpty(message.AppName) ? title : message.AppName, + message.InstallDir ?? string.Empty, + message.BrandingImage).GetAwaiter().GetResult(); UiTrace.Write("welcome_response", new { Result = welcomeResult }); WriteResponse(new UiResponse diff --git a/ui/Covenant.Setup.Ui/InstallerUiWindow.cs b/ui/Covenant.Setup.Ui/InstallerUiWindow.cs index 7e53254..359dca5 100644 --- a/ui/Covenant.Setup.Ui/InstallerUiWindow.cs +++ b/ui/Covenant.Setup.Ui/InstallerUiWindow.cs @@ -4,6 +4,7 @@ using Microsoft.UI; using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Windows.ApplicationModel.DataTransfer; @@ -36,6 +37,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView private TextBlock _welcomeHeaderTitle = null!; private TextBlock _welcomeInfoText = null!; private TextBlock _welcomePathText = null!; + private Border _welcomePathBorder = null!; private Image _brandingImage = null!; private FrameworkElement _brandingPlaceholder = null!; private TextBlock _welcomeTitle = null!; @@ -61,6 +63,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView Maximum = 100, Height = 24 }; + AutomationProperties.SetAutomationId(_progressBar, "ProgressBar"); _logBox = new TextBox { @@ -70,6 +73,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView IsReadOnly = true, TextWrapping = TextWrapping.NoWrap }; + AutomationProperties.SetAutomationId(_logBox, "LogBox"); ScrollViewer.SetVerticalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto); ScrollViewer.SetHorizontalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto); @@ -80,6 +84,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView MinWidth = 88, Visibility = Visibility.Collapsed }; + AutomationProperties.SetAutomationId(_copyErrorButton, "CopyErrorButton"); _copyErrorButton.Click += (_, _) => CopyErrorToClipboard(); _saveErrataButton = new Button @@ -89,6 +94,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView MinWidth = 320, Visibility = Visibility.Collapsed }; + AutomationProperties.SetAutomationId(_saveErrataButton, "SaveErrataButton"); _saveErrataButton.Click += async (_, _) => await SaveErrataAsync(); _cancelButton = new Button @@ -97,6 +103,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView IsEnabled = true, MinWidth = 88 }; + AutomationProperties.SetAutomationId(_cancelButton, "CancelButton"); _cancelButton.Click += (_, _) => { // _canClose tracks which face the button shows: false = "Cancel" @@ -288,7 +295,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView FontSize = 14 }; - var pathBorder = new Border + _welcomePathBorder = new Border { Background = new SolidColorBrush(Color.FromArgb(15, 128, 128, 128)), BorderBrush = new SolidColorBrush(Color.FromArgb(30, 128, 128, 128)), @@ -305,10 +312,10 @@ internal sealed class InstallerUiWindow : Window, IInstallerView TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true }; - pathBorder.Child = _welcomePathText; + _welcomePathBorder.Child = _welcomePathText; infoPanel.Children.Add(_welcomeInfoText); - infoPanel.Children.Add(pathBorder); + infoPanel.Children.Add(_welcomePathBorder); Grid.SetRow(infoPanel, 1); rightCol.Children.Add(infoPanel); @@ -325,6 +332,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView Content = "Cancel", MinWidth = 88 }; + AutomationProperties.SetAutomationId(welcomeCancelBtn, "WelcomeCancelButton"); welcomeCancelBtn.Click += (_, _) => OnWelcomeCancelClicked(); var welcomeInstallBtn = new Button @@ -333,6 +341,7 @@ internal sealed class InstallerUiWindow : Window, IInstallerView MinWidth = 88, Style = (Style)Microsoft.UI.Xaml.Application.Current.Resources["AccentButtonStyle"] }; + AutomationProperties.SetAutomationId(welcomeInstallBtn, "WelcomeInstallButton"); welcomeInstallBtn.Click += (_, _) => OnWelcomeInstallClicked(); welcomeButtons.Children.Add(welcomeCancelBtn); @@ -390,8 +399,14 @@ internal sealed class InstallerUiWindow : Window, IInstallerView _progressPanel.Visibility = Visibility.Collapsed; _welcomeHeaderTitle.Text = $"{appName} Installer"; - _welcomeInfoText.Text = $"This installer will install {appName} to:"; + // Manifests without file or directory targets have no install + // root to show; the consent page still appears for them. + var hasInstallDir = !string.IsNullOrWhiteSpace(installDir); + _welcomeInfoText.Text = hasInstallDir + ? $"This installer will install {appName} to:" + : $"This installer will install {appName}."; _welcomePathText.Text = installDir; + _welcomePathBorder.Visibility = hasInstallDir ? Visibility.Visible : Visibility.Collapsed; _welcomeTitle.Text = appName; if (!string.IsNullOrEmpty(brandingImage) && File.Exists(brandingImage)) diff --git a/ui/Covenant.Setup.Ui/Mocking/ScenarioBuilder.cs b/ui/Covenant.Setup.Ui/Mocking/ScenarioBuilder.cs index a230634..5d885f1 100644 --- a/ui/Covenant.Setup.Ui/Mocking/ScenarioBuilder.cs +++ b/ui/Covenant.Setup.Ui/Mocking/ScenarioBuilder.cs @@ -19,7 +19,7 @@ internal sealed class ScenarioBuilder return Init(title, message, appName: null, installDir: null); } - public ScenarioBuilder Init(string title, string message, string? appName, string? installDir, string? brandingImage = null) + public ScenarioBuilder Init(string title, string message, string? appName, string? installDir, string? brandingImage = null, bool showWelcome = false) { var msg = new UiMessage { @@ -28,7 +28,8 @@ internal sealed class ScenarioBuilder Message = message, AppName = appName, InstallDir = installDir, - BrandingImage = brandingImage + BrandingImage = brandingImage, + ShowWelcome = showWelcome ? true : null }; return Raw(msg); } diff --git a/ui/Covenant.Setup.Ui/Scenarios/install-cancel-rollback.jsonl b/ui/Covenant.Setup.Ui/Scenarios/install-cancel-rollback.jsonl index 7e2d11e..596690e 100644 --- a/ui/Covenant.Setup.Ui/Scenarios/install-cancel-rollback.jsonl +++ b/ui/Covenant.Setup.Ui/Scenarios/install-cancel-rollback.jsonl @@ -1,6 +1,6 @@ # install-cancel-rollback: user clicks Cancel mid-install, engine rolls back in the same window # The scenario blocks at await_response until the Cancel button is clicked. -{"type":"init","title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} +{"type":"init","show_welcome":true,"title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} {"await_response":{"id":"welcome","expect":"install"}} {"wait_ms": 400} {"type":"progress","current_step":1,"total_steps":8,"message":"Creating directories"} diff --git a/ui/Covenant.Setup.Ui/Scenarios/install-fail-errata.jsonl b/ui/Covenant.Setup.Ui/Scenarios/install-fail-errata.jsonl index 32d7f53..fdb91b5 100644 --- a/ui/Covenant.Setup.Ui/Scenarios/install-fail-errata.jsonl +++ b/ui/Covenant.Setup.Ui/Scenarios/install-fail-errata.jsonl @@ -1,5 +1,5 @@ # install-fail-errata: failure with errata details -{"type":"init","title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} +{"type":"init","show_welcome":true,"title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} {"await_response":{"id":"welcome","expect":"install"}} {"wait_ms": 200} {"type":"progress","current_step":1,"total_steps":8,"message":"Creating directories"} diff --git a/ui/Covenant.Setup.Ui/Scenarios/install-happy.jsonl b/ui/Covenant.Setup.Ui/Scenarios/install-happy.jsonl index da9a4e2..afd4707 100644 --- a/ui/Covenant.Setup.Ui/Scenarios/install-happy.jsonl +++ b/ui/Covenant.Setup.Ui/Scenarios/install-happy.jsonl @@ -1,4 +1,4 @@ -{"type":"init","title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} +{"type":"init","show_welcome":true,"title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} {"await_response":{"id":"welcome","expect":"install"}} {"wait_ms":200} {"type":"progress","current_step":1,"total_steps":8,"message":"Creating directories"} diff --git a/ui/Covenant.Setup.Ui/Scenarios/install-prompt.jsonl b/ui/Covenant.Setup.Ui/Scenarios/install-prompt.jsonl index 0078708..1594011 100644 --- a/ui/Covenant.Setup.Ui/Scenarios/install-prompt.jsonl +++ b/ui/Covenant.Setup.Ui/Scenarios/install-prompt.jsonl @@ -2,7 +2,7 @@ {"wait_ms": 400} {"type":"prompt","id":"p1","title":"covenant-setup","message":"Install SampleApp now?","buttons":"ok_cancel","icon":"information"} {"await_response":{"id":"p1","expect":"ok"}} -{"type":"init","title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} +{"type":"init","show_welcome":true,"title":"Installing SampleApp","message":"Preparing...","total_steps":8,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} {"await_response":{"id":"welcome","expect":"install"}} {"wait_ms": 200} {"type":"progress","current_step":1,"total_steps":8,"message":"Creating directories"} diff --git a/ui/Covenant.Setup.Ui/Scenarios/install-slow.jsonl b/ui/Covenant.Setup.Ui/Scenarios/install-slow.jsonl index 8673b11..9e8cec6 100644 --- a/ui/Covenant.Setup.Ui/Scenarios/install-slow.jsonl +++ b/ui/Covenant.Setup.Ui/Scenarios/install-slow.jsonl @@ -1,5 +1,5 @@ # install-slow: long visual test with many log lines -{"type":"init","title":"Installing SampleApp","message":"Preparing to install SampleApp...","total_steps":12,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} +{"type":"init","show_welcome":true,"title":"Installing SampleApp","message":"Preparing to install SampleApp...","total_steps":12,"app_name":"SampleApp","install_dir":"C:\\Program Files\\SampleApp"} {"await_response":{"id":"welcome","expect":"install"}} {"wait_ms":800} {"type":"progress","current_step":1,"total_steps":12,"message":"Checking system pre-requisites"} diff --git a/ui/Covenant.Setup.Ui/UiProtocol.cs b/ui/Covenant.Setup.Ui/UiProtocol.cs index f2caffe..7323132 100644 --- a/ui/Covenant.Setup.Ui/UiProtocol.cs +++ b/ui/Covenant.Setup.Ui/UiProtocol.cs @@ -154,8 +154,8 @@ internal sealed class UiMessage [JsonPropertyName("branding_image")] public string? BrandingImage { get; set; } - [JsonPropertyName("automation")] - public bool? Automation { get; set; } + [JsonPropertyName("show_welcome")] + public bool? ShowWelcome { get; set; } } internal sealed class UiResponse