From fb6d7eb800973f3812a01fb9083b976d0e5a9750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 27 Oct 2026 09:58:37 -0400 Subject: [PATCH 1/2] Support update-all and update-manager in headless sessions Only the desktop window handled these commands, so a --headless session rejected them. The server now asks the host for the operations it queued and, without a window, queues the updates itself from the update list (so ignored updates, skipped minor updates and the minimum update age still apply). Both modes return the queued operation ids. The CLI gains --manager on update-all, plus --wait, --timeout and --delay, which poll the queued operations and exit with 1 if any did not succeed or the wait timed out. Co-Authored-By: Claude Opus 5.5 --- docs/CLI.md | 6 +- docs/IPC.md | 38 +++++- .../Infrastructure/AvaloniaBootstrapper.cs | 10 +- .../AvaloniaPackageOperationHelper.cs | 31 +++-- .../IpcCliCommandRunner.cs | 34 +++++- src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs | 5 +- src/UniGetUI.Interface.IpcApi/IpcClient.cs | 110 ++++++++++++++++- src/UniGetUI.Interface.IpcApi/IpcJson.cs | 1 + .../IpcPackageApi.cs | 86 +++++++++++++ src/UniGetUI.Interface.IpcApi/IpcServer.cs | 75 +++++++----- src/UniGetUI.Tests/IpcBulkUpdateTests.cs | 113 ++++++++++++++++++ 11 files changed, 443 insertions(+), 66 deletions(-) create mode 100644 src/UniGetUI.Tests/IpcBulkUpdateTests.cs diff --git a/docs/CLI.md b/docs/CLI.md index ce9a11f89a..e9ceb0faa9 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -310,8 +310,8 @@ Windows only. A folder rule names the subfolder of the current user's Start Menu | `package ignored list` | None | None | Lists ignored-update rules tracked by UniGetUI. | | `package ignored add` | `--id ` | `--manager `, `--version `, `--source ` | Adds an ignored-update rule. | | `package ignored remove` | `--id ` | `--manager `, `--version `, `--source ` | Removes an ignored-update rule. | -| `package update-all` | None | None | Queues updates for all currently upgradable packages. | -| `package update-manager` | `--manager ` | None | Queues updates for all upgradable packages handled by one manager. | +| `package update-all` | None | `--manager `, `--wait true\|false`, `--timeout `, `--delay ` | Queues an update for every available update and returns their operation ids. With `--wait true`, waits for all of them (default timeout 3600 seconds) and exits with `1` if any did not succeed or the wait timed out. Works in headless sessions. | +| `package update-manager` | `--manager ` | `--wait true\|false`, `--timeout `, `--delay ` | Same as `package update-all --manager `. | ## Headless behavior @@ -323,7 +323,7 @@ When UniGetUI is started with `--headless`, it exposes the same automation API w | `app show` | Fails with “the current UniGetUI session is running headless and has no window to show.” | | `app navigate` | Fails with “the current UniGetUI session is running headless and cannot navigate UI pages.” | | `package show` | UI-oriented; may fail or be meaningless in pure headless sessions. | -| `package update-all`, `package update-manager` | Require GUI-side upgrade handlers. Headless sessions may return “cannot update all packages” or “cannot update manager packages.” | +| `package update-all`, `package update-manager` | Fully supported. The headless session queues the updates itself. | ## Headless IPC options diff --git a/docs/IPC.md b/docs/IPC.md index f49d448956..de1349f1ae 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -405,8 +405,40 @@ packages this machine is waiting on a restart for. See | `POST` | `/uniget/v1/packages/uninstall` | Yes | Package action query set | `package uninstall` | Uninstalls package. | | `POST` | `/uniget/v1/packages/uninstall-then-reinstall` | Yes | Package action query set | `package repair` | Repair flow. | | `POST` | `/uniget/v1/packages/show` | Yes | Query `packageId`, `packageSource` | `package show` | UI-oriented package-details flow. | -| `POST` | `/uniget/v1/packages/update-all` | Yes | None | `package update-all` | Requires `OnUpgradeAll` handler to be wired. | -| `POST` | `/uniget/v1/packages/update-manager` | Yes | Query `manager` | `package update-manager` | Requires `OnUpgradeAllForManager` handler to be wired. | +| `POST` | `/uniget/v1/packages/update-all` | Yes | Optional query `manager` | `package update-all` | Queues an update for every available update and returns their operation ids. See [Updating everything](#updating-everything). | +| `POST` | `/uniget/v1/packages/update-manager` | Yes | Query `manager` | `package update-manager` | Same as `update-all` with `manager` set. | + +## Updating everything + +`update-all` and `update-manager` queue one update operation per available update and return at +once; they never wait for the updates themselves. They update what the Software Updates page +lists, so ignored updates, skipped minor updates and the minimum update age apply, and a package +that already has an update queued or running is skipped. If the update list has not been loaded +yet, the request waits for that first load. + +The response lists the operations it queued: + +```json +{ + "status": "success", + "command": "update-all", + "completed": false, + "message": "The updates were queued for background execution.", + "queuedCount": 2, + "succeededCount": 0, + "failedCount": 0, + "results": [ + { "status": "success", "operationId": "…", "operationStatus": "inqueue", "completed": false, "package": { "id": "Git.Git", "manager": "winget" } } + ] +} +``` + +Follow each one through `/uniget/v1/operations/{operationId}`, or let the CLI do it with +`package update-all --wait true`. With no updates available, `queuedCount` is `0`, `completed` is +`true` and `results` is empty. + +In a session with a window, the window queues the updates, so they also appear in its operations +list. A headless session queues them itself. ## Pending machine restarts @@ -430,8 +462,6 @@ In headless sessions: - `POST /uniget/v1/app/show` fails because there is no window to show. - `POST /uniget/v1/app/navigate` fails because there is no UI page stack to navigate. -- `POST /uniget/v1/packages/update-all` fails unless a host wires `OnUpgradeAll`. -- `POST /uniget/v1/packages/update-manager` fails unless a host wires `OnUpgradeAllForManager`. These failures are intentional and surfaced as HTTP `400` with a descriptive message. diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index 1025d5e04f..389581c797 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -217,12 +217,10 @@ private static async Task InitializeIpcApiAsync() _ipcApi.ShowPackageHandler = request => Dispatcher.UIThread.InvokeAsync(() => ShowPackage(request)).GetAwaiter().GetResult(); - _ipcApi.OnUpgradeAll += (_, _) => - Dispatcher.UIThread.Post(() => _ = AvaloniaPackageOperationHelper.UpdateAllAsync()); - - _ipcApi.OnUpgradeAllForManager += (_, managerName) => - Dispatcher.UIThread.Post(() => - _ = AvaloniaPackageOperationHelper.UpdateAllForManagerAsync(managerName)); + _ipcApi.UpdateAllHandler = managerName => + Dispatcher.UIThread.InvokeAsync(() => managerName is null + ? AvaloniaPackageOperationHelper.UpdateAllAsync() + : AvaloniaPackageOperationHelper.UpdateAllForManagerAsync(managerName)); await _ipcApi.Start(); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs index 7af0f32bb7..27ac0a6f40 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs @@ -30,9 +30,19 @@ namespace UniGetUI.Avalonia.Infrastructure; /// internal static class AvaloniaPackageOperationHelper { - public static async Task UpdateAllAsync(bool background = false) + public static Task> UpdateAllAsync(bool background = false) + => UpdatePackagesAsync(UpgradablePackagesLoader.Instance.Packages.ToList(), background); + + public static Task> UpdateAllForManagerAsync(string managerName) + => UpdatePackagesAsync(UpgradablePackagesLoader.Instance.Packages + .Where(p => p.Manager.Id.Equals(managerName, StringComparison.OrdinalIgnoreCase)) + .ToList()); + + private static async Task> UpdatePackagesAsync( + IReadOnlyList packages, bool background = false) { - foreach (var pkg in UpgradablePackagesLoader.Instance.Packages.ToList()) + List started = []; + foreach (var pkg in packages) { var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); if (PackageOperation.HasPendingOperation(pkg, OperationType.Update)) continue; @@ -41,23 +51,10 @@ public static async Task UpdateAllAsync(bool background = false) op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); + started.Add(op); } - } - public static async Task UpdateAllForManagerAsync(string managerName) - { - foreach (var pkg in UpgradablePackagesLoader.Instance.Packages - .Where(p => p.Manager.Id == managerName) - .ToList()) - { - var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); - if (PackageOperation.HasPendingOperation(pkg, OperationType.Update)) continue; - var op = new UpdatePackageOperation(pkg, opts); - op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); - op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); - AvaloniaOperationRegistry.Add(op); - _ = op.MainThread(); - } + return started; } public static async Task UpdateForIdAsync(string packageId) diff --git a/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs b/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs index df4d7d9643..b71b0cffd7 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcCliCommandRunner.cs @@ -531,9 +531,16 @@ await client.ShowPackageAsync( ) ) ), - "update-all" => await WriteJsonAsync(output, await client.UpdateAllAsync()), - "update-manager" => await WriteJsonAsync( + "update-all" => await WriteBulkOperationAsync( output, + client, + args, + await client.UpdateAllAsync(GetOptionalArgument(args, "--manager")) + ), + "update-manager" => await WriteBulkOperationAsync( + output, + client, + args, await client.UpdateManagerAsync( GetRequiredArgument( args, @@ -915,6 +922,29 @@ await output.WriteLineAsync( return (int)IpcCliExitCode.Success; } + private static async Task WriteBulkOperationAsync( + TextWriter output, + IpcClient client, + IReadOnlyList args, + IpcPackageBulkOperationResult queued + ) + { + if (GetOptionalBoolArgument(args, "--wait") != true) + { + return await WriteJsonAsync(output, queued); + } + + var result = await client.WaitForBulkOperationAsync( + queued, + GetOptionalIntArgument(args, "--timeout") ?? 3600, + (GetOptionalIntArgument(args, "--delay") ?? 1) * 1000 + ); + await output.WriteLineAsync(IpcJson.Serialize(result)); + return result.Status == "success" + ? (int)IpcCliExitCode.Success + : (int)IpcCliExitCode.Failed; + } + private static async Task WriteWrappedJsonAsync( TextWriter output, string propertyName, diff --git a/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs b/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs index a5eed168bb..7412c076af 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcCliSyntax.cs @@ -180,8 +180,8 @@ backup github logout bundle get|reset|import|export|add|remove|install package search|details|versions|installed|updates|install|download|reinstall|repair|update|uninstall|show package ignored list|add|remove - package update-all - package update-manager + package update-all [--manager ] [--wait true|false] [--timeout ] + package update-manager --manager [--wait true|false] [--timeout ] Examples: unigetui status @@ -189,6 +189,7 @@ unigetui app status unigetui package search --manager dotnet-tool --query dotnetsay unigetui package install --manager dotnet-tool --id dotnetsay --version 2.1.4 --scope Global unigetui operation wait --id 123 --timeout 300 + unigetui package update-all --wait true unigetui backup local create unigetui backup github login start --launch-browser """; diff --git a/src/UniGetUI.Interface.IpcApi/IpcClient.cs b/src/UniGetUI.Interface.IpcApi/IpcClient.cs index 4299316703..9e20021078 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcClient.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcClient.cs @@ -1090,19 +1090,104 @@ IpcPackageActionRequest request ); } - public async Task UpdateAllAsync() + public async Task UpdateAllAsync(string? managerName = null) { - return await SendCommandAsync(IpcHttpRoutes.Path("/packages/update-all")); + return await SendBulkOperationAsync( + IpcHttpRoutes.Path("/packages/update-all"), + string.IsNullOrWhiteSpace(managerName) + ? null + : new Dictionary { ["manager"] = managerName } + ); } - public async Task UpdateManagerAsync(string managerName) + public async Task UpdateManagerAsync(string managerName) { - return await SendCommandAsync( + return await SendBulkOperationAsync( IpcHttpRoutes.Path("/packages/update-manager"), new Dictionary { ["manager"] = managerName } ); } + /// + /// Waits for every operation a bulk command queued. On timeout, returns the statuses reached + /// so far with Completed unset; the remaining operations keep running. + /// + public async Task WaitForBulkOperationAsync( + IpcPackageBulkOperationResult queued, + int timeoutSeconds = 3600, + int delayMilliseconds = 1000 + ) + { + if (queued.Completed || queued.Status != "success") + { + return queued; + } + + timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 86400); + delayMilliseconds = Math.Clamp(delayMilliseconds, 100, 10000); + + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (true) + { + var operations = (await ListOperationsAsync()).ToDictionary(operation => operation.Id); + var results = queued.Results + .Select(result => ToWaitedResult( + result, + operations.GetValueOrDefault(result.OperationId) + )) + .ToArray(); + bool completed = results.All(result => result.Completed); + + if (completed || DateTime.UtcNow >= deadline) + { + int failed = results.Count(result => result.Completed && result.Status == "error"); + return new IpcPackageBulkOperationResult + { + Status = completed && failed == 0 ? "success" : "error", + Command = queued.Command, + Completed = completed, + Message = completed + ? failed == 0 ? null : $"{failed} of {results.Length} updates did not succeed." + : $"Timed out while waiting for {results.Count(result => !result.Completed)} of {results.Length} updates. They keep running in UniGetUI.", + QueuedCount = queued.QueuedCount, + SucceededCount = results.Count(result => result.Completed && result.Status == "success"), + FailedCount = failed, + Results = results, + }; + } + + await Task.Delay(delayMilliseconds); + } + } + + internal static IpcPackageOperationResult ToWaitedResult( + IpcPackageOperationResult queued, + IpcOperationInfo? operation + ) + { + // An operation drops out of the list when it is forgotten or pruned, which only + // happens once it has finished. + string status = operation?.Status ?? "unknown"; + bool completed = status is "succeeded" or "failed" or "canceled" or "unknown"; + return new IpcPackageOperationResult + { + Status = !completed || status == "succeeded" ? "success" : "error", + Command = queued.Command, + OperationId = queued.OperationId, + OperationStatus = status, + Completed = completed, + Message = status switch + { + "succeeded" => null, + "canceled" => "The operation was canceled.", + "unknown" => "UniGetUI no longer tracks this operation, so its result is unknown.", + _ when !completed => queued.Message, + _ => operation?.LiveLine, + }, + Package = operation?.Package ?? queued.Package, + }; + } + public async Task InstallPackageAsync( IpcPackageActionRequest request ) @@ -1246,6 +1331,23 @@ IpcPackageActionRequest request }; } + private async Task SendBulkOperationAsync( + string relativePath, + IReadOnlyDictionary? queryParameters + ) + { + return await ReadAuthenticatedJsonAsync( + HttpMethod.Post, + relativePath, + queryParameters + ) + ?? new IpcPackageBulkOperationResult + { + Status = "error", + Message = "The IPC API returned an empty response.", + }; + } + private async Task SendSourceOperationAsync( string relativePath, IpcSourceRequest request diff --git a/src/UniGetUI.Interface.IpcApi/IpcJson.cs b/src/UniGetUI.Interface.IpcApi/IpcJson.cs index 2a74a3480e..5ed3c39c76 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcJson.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcJson.cs @@ -127,6 +127,7 @@ internal static Task WriteAsJsonAsync( [JsonSerializable(typeof(IpcPackageInfo))] [JsonSerializable(typeof(IpcPackageActionRequest))] [JsonSerializable(typeof(IpcPackageOperationResult))] +[JsonSerializable(typeof(IpcPackageBulkOperationResult))] [JsonSerializable(typeof(IpcPackageDependencyInfo))] [JsonSerializable(typeof(IpcPackageDetailsInfo))] [JsonSerializable(typeof(IpcIgnoredUpdateInfo))] diff --git a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs index 2c26359ed8..75542c4fca 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs @@ -56,6 +56,18 @@ public sealed class IpcPackageOperationResult public IReadOnlyList Output { get; set; } = []; } +public sealed class IpcPackageBulkOperationResult +{ + public string Status { get; set; } = "success"; + public string Command { get; set; } = ""; + public bool Completed { get; set; } + public string? Message { get; set; } + public int QueuedCount { get; set; } + public int SucceededCount { get; set; } + public int FailedCount { get; set; } + public IReadOnlyList Results { get; set; } = []; +} + public sealed class IpcPackageDependencyInfo { public string Name { get; set; } = ""; @@ -193,6 +205,80 @@ IpcPackageActionRequest request ); } + /// + /// Queues an update for every available update, or for those of one manager, and returns the + /// operations it started. Sessions with a window queue them through the window instead, so + /// they show in its operations list; this is what a headless session uses. + /// + public static async Task> QueueAllUpdatesAsync( + string? managerName = null + ) + { + List operations = []; + foreach (var package in await GetUpdatesToApplyAsync(managerName)) + { + var options = await InstallOptionsFactory.LoadApplicableAsync(package); + if (PackageOperation.HasPendingOperation(package, OperationType.Update)) + { + continue; + } + + var operation = new UpdatePackageOperation(package, options); + IpcOperationApi.Track(operation); + _ = Task.Run(() => RunTrackedOperationAsync(operation)); + operations.Add(operation); + } + + return operations; + } + + internal static IpcPackageBulkOperationResult CreateQueuedBulkResult( + string command, + IReadOnlyList operations + ) + { + var results = operations + .OfType() + .Select(operation => + CreateOperationResult(command, operation.Package, operation, completed: false) + ) + .ToArray(); + + return new IpcPackageBulkOperationResult + { + Command = command, + Completed = results.Length == 0, + Message = results.Length == 0 + ? "No updates are available." + : "The updates were queued for background execution.", + QueuedCount = results.Length, + Results = results, + }; + } + + // Reads the update list the app shows, never the managers' raw lists: only the loader applies + // ignored updates, skipped minor updates and the minimum update age. + private static async Task> GetUpdatesToApplyAsync(string? managerName) + { + var loader = UpgradablePackagesLoader.Instance + ?? throw new InvalidOperationException( + "The current UniGetUI session has not loaded its package updates." + ); + + if (loader.IsLoading) + { + await loader.WaitForCurrentLoadAsync(); + } + else if (!loader.IsLoaded) + { + await loader.ReloadPackages(); + } + + return loader.Packages + .Where(package => MatchesManager(package.Manager, managerName)) + .ToArray(); + } + public static Task UninstallPackageAsync( IpcPackageActionRequest request ) diff --git a/src/UniGetUI.Interface.IpcApi/IpcServer.cs b/src/UniGetUI.Interface.IpcApi/IpcServer.cs index 6978d2ad54..a66ccec55c 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcServer.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcServer.cs @@ -16,6 +16,7 @@ using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageOperations; namespace UniGetUI.Interface { @@ -39,8 +40,12 @@ public class IpcServer /// public bool HandlesTerminationSignals { get; init; } = true; - public event EventHandler? OnUpgradeAll; - public event EventHandler? OnUpgradeAllForManager; + /// + /// Queues the updates for update-all and update-manager (a null manager means every + /// manager) and returns the operations it started. A host with a window sets this so the + /// operations show in it; without it, the updates are queued headless. + /// + public Func>>? UpdateAllHandler; public Func? AppInfoProvider; public Func? ShowAppHandler; public Func? NavigateAppHandler; @@ -1780,41 +1785,55 @@ await context.Response.WriteAsJsonAsync( private async Task V3_UpdateAllPackages(HttpContext context) { - await HandleCommandAsync( + await HandleUpdateAllAsync( context, - () => - { - if (OnUpgradeAll is null) - { - throw new InvalidOperationException( - "The current UniGetUI session cannot update all packages." - ); - } - - OnUpgradeAll.Invoke(null, EventArgs.Empty); - return IpcCommandResult.Success("update-all"); - } + "update-all", + () => GetOptionalQueryValue(context.Request, "manager") ); } private async Task V3_UpdateAllPackagesForManager(HttpContext context) { - await HandleCommandAsync( + await HandleUpdateAllAsync( context, - () => + "update-manager", + () => GetRequiredQueryValue(context, "manager") + ); + } + + private async Task HandleUpdateAllAsync( + HttpContext context, + string command, + Func getManagerName + ) + { + if (!AuthenticateToken(context.Request.Query["token"])) + { + context.Response.StatusCode = 401; + return; + } + + try + { + string? managerName = getManagerName(); + if (managerName is not null) { - if (OnUpgradeAllForManager is null) - { - throw new InvalidOperationException( - "The current UniGetUI session cannot update manager packages." - ); - } - - string managerName = GetRequiredQueryValue(context, "manager"); - OnUpgradeAllForManager.Invoke(null, managerName); - return IpcCommandResult.Success("update-manager"); + managerName = IpcManagerSettingsApi.ResolveManager(managerName).Id; } - ); + + var operations = await (UpdateAllHandler ?? IpcPackageApi.QueueAllUpdatesAsync)( + managerName + ); + await context.Response.WriteAsJsonAsync( + IpcPackageApi.CreateQueuedBulkResult(command, operations), + IpcJson.Options + ); + } + catch (InvalidOperationException ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync(ex.Message); + } } private static async Task HandlePackageActionAsync( diff --git a/src/UniGetUI.Tests/IpcBulkUpdateTests.cs b/src/UniGetUI.Tests/IpcBulkUpdateTests.cs new file mode 100644 index 0000000000..3a0edb3cc9 --- /dev/null +++ b/src/UniGetUI.Tests/IpcBulkUpdateTests.cs @@ -0,0 +1,113 @@ +using UniGetUI.Interface; + +namespace UniGetUI.Tests; + +public sealed class IpcBulkUpdateTests +{ + private static IpcPackageOperationResult Queued(string operationId) => new() + { + Command = "update-all", + OperationId = operationId, + OperationStatus = "inqueue", + Completed = false, + Message = "The operation was queued for background execution.", + Package = new IpcPackageInfo { Id = "Git.Git", Manager = "winget" }, + }; + + [Fact] + public void ParseKeepsUpdateAllOptions() + { + IpcCliParseResult result = IpcCliSyntax.Parse( + ["package", "update-all", "--manager", "winget", "--wait", "true", "--timeout", "60"] + ); + + Assert.Equal(IpcCliParseStatus.Success, result.Status); + Assert.Equal("update-all", result.Command); + Assert.Equal( + ["--manager", "winget", "--wait", "true", "--timeout", "60"], + Assert.IsType(result.EffectiveArgs) + ); + } + + [Fact] + public void QueuedResultWithNoUpdatesIsComplete() + { + IpcPackageBulkOperationResult result = IpcPackageApi.CreateQueuedBulkResult("update-all", []); + + Assert.Equal("success", result.Status); + Assert.True(result.Completed); + Assert.Equal(0, result.QueuedCount); + Assert.Empty(result.Results); + Assert.Equal("No updates are available.", result.Message); + } + + [Fact] + public void BulkResultRoundTripsThroughTheIpcJsonContext() + { + var result = new IpcPackageBulkOperationResult + { + Command = "update-manager", + QueuedCount = 1, + Results = [Queued("op-1")], + }; + + IpcPackageBulkOperationResult? parsed = IpcJson.Deserialize( + IpcJson.Serialize(result) + ); + + Assert.NotNull(parsed); + Assert.Equal("update-manager", parsed.Command); + Assert.Equal(1, parsed.QueuedCount); + Assert.Equal("op-1", Assert.Single(parsed.Results).OperationId); + } + + [Fact] + public void WaitedResultStaysPendingWhileTheOperationRuns() + { + IpcPackageOperationResult result = IpcClient.ToWaitedResult( + Queued("op-1"), + new IpcOperationInfo { Id = "op-1", Status = "running" } + ); + + Assert.False(result.Completed); + Assert.Equal("success", result.Status); + Assert.Equal("running", result.OperationStatus); + } + + [Fact] + public void WaitedResultReportsSuccess() + { + IpcPackageOperationResult result = IpcClient.ToWaitedResult( + Queued("op-1"), + new IpcOperationInfo { Id = "op-1", Status = "succeeded" } + ); + + Assert.True(result.Completed); + Assert.Equal("success", result.Status); + Assert.Null(result.Message); + } + + [Fact] + public void WaitedResultReportsFailureWithTheLastLine() + { + IpcPackageOperationResult result = IpcClient.ToWaitedResult( + Queued("op-1"), + new IpcOperationInfo { Id = "op-1", Status = "failed", LiveLine = "Installer exited with code 1603" } + ); + + Assert.True(result.Completed); + Assert.Equal("error", result.Status); + Assert.Equal("Installer exited with code 1603", result.Message); + } + + [Fact] + public void WaitedResultTreatsAnUntrackedOperationAsFailed() + { + IpcPackageOperationResult result = IpcClient.ToWaitedResult(Queued("op-1"), null); + + Assert.True(result.Completed); + Assert.Equal("error", result.Status); + Assert.Equal("unknown", result.OperationStatus); + Assert.Equal("Git.Git", result.Package?.Id); + } +} From e4f0ea065f7d66541b1cd9903f219f1b00963f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 27 Oct 2026 10:13:25 -0400 Subject: [PATCH 2/2] Test headless update-all and update-manager in the CLI E2E The E2E still expected both commands to fail headless. Run them with --wait true, scoped to the manifest's update-manager and update-all fixture managers, since an unscoped update-all would update whatever the runner image has installed. Also check that an unknown manager is rejected. Co-Authored-By: Claude Opus 5.5 --- testing/automation/run-cli-e2e.ps1 | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/testing/automation/run-cli-e2e.ps1 b/testing/automation/run-cli-e2e.ps1 index 8e0ca8027b..65f15730b7 100644 --- a/testing/automation/run-cli-e2e.ps1 +++ b/testing/automation/run-cli-e2e.ps1 @@ -1033,17 +1033,31 @@ try { Wait-ForInstalledPackage -Fixture $specificUpdateFixture -ExpectedVersion ([string]$fixtureState[$specificUpdateFixture.manager].latestVersion) | Out-Null Add-Tested 'package update' - $updateManager = Invoke-CliFailure -Arguments @('package', 'update-manager', '--manager', [string]$bundleFixture.manager) - if ($updateManager.Text -notmatch 'cannot update manager packages') { - throw "package update-manager did not report the expected headless limitation: $($updateManager.Text)" + # Both bulk updates are scoped to a fixture manager: an unscoped update-all would update + # whatever else the runner image has installed. + foreach ($bulkUpdate in @( + @{ Command = 'update-manager'; Role = 'update-manager' }, + @{ Command = 'update-all'; Role = 'update-all' } + )) { + $bulkFixture = Get-ManifestManagerByRole -Role $bulkUpdate.Role + if ($null -eq $bulkFixture) { + throw "The CLI E2E manifest must define a package manager fixture with the $($bulkUpdate.Role) role." + } + $bulkResult = Invoke-CliJson -Arguments @('package', $bulkUpdate.Command, '--manager', [string]$bulkFixture.manager, '--wait', 'true', '--timeout', '600') + Assert-JsonCommandSucceeded -Response $bulkResult -Label "package $($bulkUpdate.Command) $($bulkFixture.manager)" + if (-not $bulkResult.completed) { + throw "package $($bulkUpdate.Command) did not wait for its updates: $($bulkResult | ConvertTo-Json -Depth 10)" + } + if (@($bulkResult.results | Where-Object { $_.package.manager -ne $bulkFixture.manager }).Count -ne 0) { + throw "package $($bulkUpdate.Command) --manager $($bulkFixture.manager) queued updates for another manager: $($bulkResult | ConvertTo-Json -Depth 10)" + } + Add-Tested "package $($bulkUpdate.Command)" } - Add-Tested 'package update-manager' 'expected-failure' - $updateAll = Invoke-CliFailure -Arguments @('package', 'update-all') - if ($updateAll.Text -notmatch 'cannot update all packages') { - throw "package update-all did not report the expected headless limitation: $($updateAll.Text)" + $unknownManagerUpdate = Invoke-CliFailure -Arguments @('package', 'update-all', '--manager', 'no-such-manager') + if ($unknownManagerUpdate.Text -notmatch 'No package manager matching') { + throw "package update-all did not reject an unknown manager: $($unknownManagerUpdate.Text)" } - Add-Tested 'package update-all' 'expected-failure' $reinstall = Invoke-CliJson -Arguments (@('package', 'reinstall') + (Get-PackageArguments -Fixture $bundleFixture -IncludeSource)) Assert-JsonCommandSucceeded -Response $reinstall -Label 'package reinstall'