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); + } +} 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'