From 68efb89fc77a1a814db64766f60b7cabab6650dc Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Tue, 20 Oct 2026 01:12:48 +0000 Subject: [PATCH] =?UTF-8?q?release:=201.2.0=20=E2=80=94=20suppress=20post-?= =?UTF-8?q?run=20banner=20after=20update=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-run banner compared the latest tag with the running process's version, which is still the old binary after an install swaps the files, so right after `update` it offered the version just installed. It also repeated `update check` and a declined `update`. An internal process-wide UpdateSession now records that the update UX has shown the latest release (set by UpdateCommand and UpdateCheckCommand) and which tag this process installed (set by UpdateInstaller on success). RenderIfAvailable is a no-op in either case. New public UpdateBanner.Suppress() lets apps with their own update UX do the same. Tests isolate the static state per test via an AsyncLocal test scope rather than a serial collection, so writers such as the installer tests need no coordination. Version 1.2.0; PackageValidationBaselineVersion moves to 1.1.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 36 +++++++++- README.md | 4 +- .../Commands/UpdateCheckCommand.cs | 6 ++ .../Commands/UpdateCommand.cs | 6 ++ .../Internal/UpdateSession.cs | 64 +++++++++++++++++ ...Iteration.SpectreConsole.SelfUpdate.csproj | 11 ++- .../Pipeline/UpdateInstaller.cs | 6 ++ .../UpdateBanner.cs | 37 ++++++++++ .../Commands/UpdateCheckCommandTests.cs | 27 +++++++ .../Commands/UpdateCommandTests.cs | 70 +++++++++++++++++++ .../Pipeline/UpdateInstallerTests.cs | 7 ++ .../UpdateBannerTests.cs | 46 ++++++++++++ 12 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 src/NextIteration.SpectreConsole.SelfUpdate/Internal/UpdateSession.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 957b657..a6a1087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.2.0] — 2026-10-20 + +Fixes the post-run update banner repeating what `update` / `update check` just said — most +visibly, offering the version that had just been installed — and adds +`UpdateBanner.Suppress()` for apps with their own update UX. No breaking changes. + +### Fixed + +- **The post-run banner no longer repeats what `update` or `update check` just said** ([#42](https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/issues/42)). + Before, an app following the README got the banner even after `update` had run. Right + after a successful install, it told the operator to install the version they had just + installed. The banner compares against the running process's version, and that is + still the old binary after the swap. `UpdateBanner.RenderIfAvailable` now renders nothing: + - once the built-in `update` or `update check` command has shown the latest release, + whether the operator installed it, declined, or the install failed; + - when this process has already installed a release at least as new as the one + available, so an app that calls `ISelfUpdater.InstallAsync` from its own command is + covered too. + + Apps need no changes, and the README's two-line setup keeps working. + +### Added + +- **`UpdateBanner.Suppress()`**, for apps with their own check or update UX: call it once + your command has shown the latest release, and the post-run banner stays silent for + the rest of the process. Additive public API. + +### Changed + +- **`PackageValidationBaselineVersion` moved to `1.1.0`**, the last shipped release. + +--- + ## [1.1.0] — 2026-10-06 Moves to **Spectre.Console.Cli 0.57.2**. Spectre.Console.Cli now ships on its own release @@ -304,7 +337,8 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the - Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. - SourceLink, deterministic builds, published symbol packages. -[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v1.0.1 [0.3.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.3.1 diff --git a/README.md b/README.md index df406ac..74e9e3b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Stop hand-rolling the same "where's the latest release / what's my RID / where d - **SHA-256 verification by default.** Reads from per-asset metadata when the source publishes it, or downloads a sibling `SHA256SUMS.txt`. Stack additional verifiers (minisign, cosign, Authenticode) on top via `IPackageVerifier`. - **Atomic file swap, no restarter required.** Stages under `.update/`, swaps via `.old/`, cleans up `.old/` on the next launch — the running new binary is sufficient proof the swap succeeded. - **Drop-in `update` command.** `CommandConfiguratorExtensions.AddUpdateCommand()` for a single command, `AddUpdateBranch()` for `update check` / `update apply`. -- **Background check + post-run banner.** `UpdateBanner.KickOffCheck()` and `RenderIfAvailable()` wire up the pl-app-style UX in two lines. +- **Background check + post-run banner.** `UpdateBanner.KickOffCheck()` and `RenderIfAvailable()` wire up the pl-app-style UX in two lines. The banner stays silent after `update` / `update check` has already shown the latest release; call `UpdateBanner.Suppress()` from your own update command to get the same. - **Channels & pre-releases.** `Channel` option flows through every source. - **Configurable cache, timeouts, opt-out env var, dev-build skip predicate.** - **Zero compiler warnings, full XML documentation on the public surface**, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. SourceLink, deterministic builds, embedded symbols, snupkg symbol packages. @@ -62,7 +62,7 @@ app.Configure(c => }); var exit = app.Run(args); -UpdateBanner.RenderIfAvailable(checkTask); +UpdateBanner.RenderIfAvailable(checkTask); // silent if `update` already handled it return exit; ``` diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs index 139750a..ca9f6c4 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Globalization; +using NextIteration.SpectreConsole.SelfUpdate.Internal; + using Spectre.Console; using Spectre.Console.Cli; @@ -78,6 +80,10 @@ public override async Task ExecuteAsync(CommandContext context, Settings se _console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Latest release: [bold]{info.LatestTag}[/]"); + // Every path from here has told the operator about the latest + // release, so the post-run banner would only repeat it. + UpdateSession.MarkHandled(); + if (!info.IsUpdateAvailable) { _console.MarkupLine("[green]Already up to date.[/]"); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs index 778fff7..d87eb0b 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Globalization; +using NextIteration.SpectreConsole.SelfUpdate.Internal; + using Spectre.Console; using Spectre.Console.Cli; @@ -111,6 +113,10 @@ public override async Task ExecuteAsync(CommandContext context, Settings se _console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Latest release: [bold]{release.Tag}[/]"); + // Every path from here has told the operator about the latest + // release, so the post-run banner would only repeat it. + UpdateSession.MarkHandled(); + if (!settings.Force && !IsUpdateAvailable(current, release.Tag)) { _console.MarkupLine("[green]Already up to date.[/]"); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Internal/UpdateSession.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Internal/UpdateSession.cs new file mode 100644 index 0000000..1b0fbb1 --- /dev/null +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Internal/UpdateSession.cs @@ -0,0 +1,64 @@ +namespace NextIteration.SpectreConsole.SelfUpdate.Internal +{ + /// + /// Process-wide record of what the update UX has already done in this + /// run, read by so the + /// post-run banner stays quiet after an update command has shown (or + /// installed) the latest release. + /// + /// + /// Static rather than DI-scoped: the banner helper takes no service + /// provider, and the commands' public constructors cannot take an + /// internal state type. A plain would not + /// work either — a value set inside app.Run's async flow does not + /// flow back out to Main. + /// + internal static class UpdateSession + { + private sealed class State + { + public bool Handled; + public string? InstalledTag; + } + + private static readonly State ProcessState = new(); + + // Test-only override. Tests run in parallel and share the process, so + // each test that reads or asserts this state opens its own scope; the + // scope's State object is inherited by every async flow the test + // starts, so commands and the banner inside the test see it. + private static readonly AsyncLocal TestScope = new(); + + private static State Current => TestScope.Value ?? ProcessState; + + /// The update UX has shown the latest release in this process. + public static bool IsHandled => Volatile.Read(ref Current.Handled); + + /// Tag of the release installed by this process, if any. + public static string? InstalledTag => Volatile.Read(ref Current.InstalledTag); + + public static void MarkHandled() => Volatile.Write(ref Current.Handled, true); + + public static void RecordInstalled(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + Volatile.Write(ref Current.InstalledTag, tag); + } + + /// + /// Isolate this state for the current async flow until the returned + /// scope is disposed. Tests only. + /// + internal static IDisposable BeginTestScope() + { + var previous = TestScope.Value; + TestScope.Value = new State(); + return new Scope(previous); + } + + private sealed class Scope(State? previous) : IDisposable + { + public void Dispose() => TestScope.Value = previous; + } + } +} diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index 07af6e4..ff10017 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj +++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj @@ -11,7 +11,7 @@ NextIteration.SpectreConsole.SelfUpdate - 1.1.0 + 1.2.0 Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command. true $(MSBuildThisFileDirectory)..\..\artifacts\packages @@ -24,14 +24,13 @@ icon.png - 1.0.1 + 1.1.0 diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index 16fb1db..2f2a4fb 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -1,3 +1,5 @@ +using NextIteration.SpectreConsole.SelfUpdate.Internal; + namespace NextIteration.SpectreConsole.SelfUpdate.Pipeline { /// @@ -125,6 +127,10 @@ public async Task InstallAsync( progress?.Report(new UpdateProgressEvent(UpdateStage.Swapping, 0)); await SwapAsync(sourceDir, installDir, oldDir, _options.PreservePaths, onConflict, ct).ConfigureAwait(false); progress?.Report(new UpdateProgressEvent(UpdateStage.Swapping, 1)); + + // This process is still the old binary, so its version no + // longer says what is installed. Tell the banner. + UpdateSession.RecordInstalled(release.Tag); } finally { diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs index 871c949..d9d2b4d 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; +using NextIteration.SpectreConsole.SelfUpdate.Internal; + using Spectre.Console; namespace NextIteration.SpectreConsole.SelfUpdate @@ -36,11 +38,31 @@ public static class UpdateBanner return updater.CheckAsync(); } + /// + /// Stop rendering anything for the + /// rest of this process. Call it from your own command when it has + /// already shown the operator the latest release (or installed it), + /// so the post-run banner does not repeat it. + /// + /// + /// The built-in update and update check commands + /// suppress the banner themselves, and a successful + /// + /// suppresses it for any release not newer than the one installed, so + /// apps using only those need not call this. The suppression is + /// process-wide and cannot be undone. + /// + public static void Suppress() => UpdateSession.MarkHandled(); + /// /// Wait briefly for to complete and /// render a one-line banner if an update is available. No-op if /// the task hasn't completed within , if /// it returned , or if no update is available. + /// Also a no-op after , after the built-in + /// update or update check command has shown the latest + /// release, or when this process has already installed a release at + /// least as new as the one available. /// /// The task returned by . /// Maximum time to wait. Defaults to 250 ms. @@ -52,6 +74,13 @@ public static void RenderIfAvailable( { ArgumentNullException.ThrowIfNull(checkTask); + // The update UX already told the operator about the latest + // release in this run; don't wait on the task just to repeat it. + if (UpdateSession.IsHandled) + { + return; + } + // Wait() on a faulted/cancelled task throws — keep the whole // settle-then-read sequence inside the try/catch so background- // check failures never derail the main app. @@ -77,6 +106,14 @@ public static void RenderIfAvailable( return; } + // info.CurrentVersion is the running process's version, which is + // still the old binary after an install swapped the files. + if (UpdateSession.InstalledTag is { } installed + && !Pipeline.UpdateChecker.IsNewer(installed, info.LatestTag)) + { + return; + } + var ansi = console ?? AnsiConsole.Console; if (info.ReleaseUrl is not null) { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs index eb5f82b..36237d7 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using NextIteration.SpectreConsole.SelfUpdate.Commands; +using NextIteration.SpectreConsole.SelfUpdate.Internal; using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure; using Spectre.Console; @@ -95,6 +96,22 @@ public async Task Execute_when_update_available_returns_two() Assert.Contains("Release notes", console.Output, StringComparison.Ordinal); } + [Fact] + public async Task Execute_when_update_available_suppresses_post_run_banner() + { + using var session = UpdateSession.BeginTestScope(); + var (run, _, _) = BuildHarness(checker => + { + checker.CurrentVersion = "1.0.0"; + checker.CheckImpl = _ => Task.FromResult( + new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null)); + }); + + await run(); + + Assert.Equal(string.Empty, RenderBannerAfterRun()); + } + [Fact] public async Task Execute_when_check_returns_null_returns_one() { @@ -138,6 +155,16 @@ public async Task Execute_when_no_release_url_omits_release_notes_line() // ---------- helpers ---------- + // What the README's post-run UpdateBanner.RenderIfAvailable would print + // after this command, given a background check that found v1.4.2. + private static string RenderBannerAfterRun() + { + var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); + using var banner = new TestConsole(); + UpdateBanner.RenderIfAvailable(Task.FromResult(info), waitFor: TimeSpan.FromSeconds(1), console: banner); + return banner.Output; + } + private (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action configChecker) { var checker = new StubUpdateChecker(); diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index c94c2bc..884a4c4 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using NextIteration.SpectreConsole.SelfUpdate.Commands; +using NextIteration.SpectreConsole.SelfUpdate.Internal; using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure; using Spectre.Console; @@ -171,6 +172,65 @@ public async Task Execute_does_not_call_parameterless_install() Assert.Equal(0, parameterlessCalls); } + [Fact] + public async Task Execute_when_install_succeeds_suppresses_post_run_banner() + { + using var session = UpdateSession.BeginTestScope(); + var (run, _, _) = BuildHarness( + configChecker: c => c.CurrentVersion = "1.0.0", + configUpdater: u => u.GetLatestImpl = _ => Task.FromResult(ReleaseV142)); + + await run("--yes"); + + Assert.Equal(string.Empty, RenderBannerAfterRun()); + } + + [Fact] + public async Task Execute_when_user_declines_prompt_suppresses_post_run_banner() + { + using var session = UpdateSession.BeginTestScope(); + var (run, console, _) = BuildHarness( + configChecker: c => c.CurrentVersion = "1.0.0", + configUpdater: u => u.GetLatestImpl = _ => Task.FromResult(ReleaseV142)); + console.Profile.Capabilities.Interactive = true; + console.Input.PushTextWithEnter("n"); + + await run(); + + Assert.Equal(string.Empty, RenderBannerAfterRun()); + } + + [Fact] + public async Task Execute_when_install_fails_suppresses_post_run_banner() + { + using var session = UpdateSession.BeginTestScope(); + var (run, _, _) = BuildHarness( + configChecker: c => c.CurrentVersion = "1.0.0", + configUpdater: u => + { + u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); + u.InstallReleaseImpl = (_, _, _, _) => throw new UpdateException("boom"); + }); + + await run("--yes"); + + Assert.Equal(string.Empty, RenderBannerAfterRun()); + } + + [Fact] + public async Task Execute_when_latest_release_unknown_leaves_post_run_banner() + { + // The command never showed a latest release, so the banner may + // still have something to say. + using var session = UpdateSession.BeginTestScope(); + var (run, _, _) = BuildHarness( + configUpdater: u => u.GetLatestImpl = _ => Task.FromResult(null)); + + await run("--yes"); + + Assert.Contains("New version", RenderBannerAfterRun(), StringComparison.Ordinal); + } + [Fact] public async Task Execute_when_install_throws_UpdateException_returns_three() { @@ -261,6 +321,16 @@ public async Task Execute_prints_current_and_latest_versions() // ---------- helpers ---------- + // What the README's post-run UpdateBanner.RenderIfAvailable would print + // after this command, given a background check that found v1.4.2. + private static string RenderBannerAfterRun() + { + var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); + using var banner = new TestConsole(); + UpdateBanner.RenderIfAvailable(Task.FromResult(info), waitFor: TimeSpan.FromSeconds(1), console: banner); + return banner.Output; + } + private (Runner Run, TestConsole Console, StubSelfUpdater Updater) BuildHarness( Action? configChecker = null, Action? configUpdater = null, diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index cac2a64..3d49b0b 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -1,5 +1,6 @@ using System.IO.Compression; +using NextIteration.SpectreConsole.SelfUpdate.Internal; using NextIteration.SpectreConsole.SelfUpdate.Pipeline; using NextIteration.SpectreConsole.SelfUpdate.Resolution; using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure; @@ -20,6 +21,7 @@ public sealed class UpdateInstallerTests [Fact] public async Task InstallAsync_swaps_files_into_install_directory() { + using var session = UpdateSession.BeginTestScope(); using var work = new TempDir(); var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); @@ -52,11 +54,15 @@ public async Task InstallAsync_swaps_files_into_install_directory() // Staging removed Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); + + // Recorded for the post-run banner: this process is still the old binary. + Assert.Equal("v1.4.2", UpdateSession.InstalledTag); } [Fact] public async Task InstallAsync_throws_when_no_asset_matches_rid() { + using var session = UpdateSession.BeginTestScope(); using var work = new TempDir(); var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); @@ -77,6 +83,7 @@ public async Task InstallAsync_throws_when_no_asset_matches_rid() installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None)); Assert.Contains("No release asset matches RID 'linux-arm64'", ex.Message, StringComparison.Ordinal); + Assert.Null(UpdateSession.InstalledTag); } [Fact] diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs index 5955017..057315c 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using NextIteration.SpectreConsole.SelfUpdate.Internal; using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure; using Spectre.Console; @@ -28,6 +29,8 @@ public async Task KickOffCheck_resolves_self_updater_and_returns_in_flight_task( [Fact] public void RenderIfAvailable_when_update_present_renders_banner() { + // Isolate from tests elsewhere that suppress the banner process-wide. + using var session = UpdateSession.BeginTestScope(); var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: new Uri("https://example.com/r")); var task = Task.FromResult(info); using var console = new TestConsole(); @@ -43,6 +46,8 @@ public void RenderIfAvailable_when_update_present_renders_banner() [Fact] public void RenderIfAvailable_when_no_release_url_omits_link() { + // Isolate from tests elsewhere that suppress the banner process-wide. + using var session = UpdateSession.BeginTestScope(); var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); var task = Task.FromResult(info); using var console = new TestConsole(); @@ -102,6 +107,47 @@ public void RenderIfAvailable_when_task_faulted_swallows_and_writes_nothing() Assert.Equal(string.Empty, console.Output); } + [Fact] + public void RenderIfAvailable_after_Suppress_writes_nothing() + { + using var session = UpdateSession.BeginTestScope(); + var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); + using var console = new TestConsole(); + + UpdateBanner.Suppress(); + UpdateBanner.RenderIfAvailable(Task.FromResult(info), waitFor: TimeSpan.FromSeconds(1), console: console); + + Assert.Equal(string.Empty, console.Output); + } + + [Fact] + public void RenderIfAvailable_after_installing_the_available_release_writes_nothing() + { + // The process still reports the old version after the swap; the + // recorded install is what tells the banner the update is done. + using var session = UpdateSession.BeginTestScope(); + UpdateSession.RecordInstalled("v1.4.2"); + var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); + using var console = new TestConsole(); + + UpdateBanner.RenderIfAvailable(Task.FromResult(info), waitFor: TimeSpan.FromSeconds(1), console: console); + + Assert.Equal(string.Empty, console.Output); + } + + [Fact] + public void RenderIfAvailable_after_installing_an_older_release_still_renders() + { + using var session = UpdateSession.BeginTestScope(); + UpdateSession.RecordInstalled("v1.3.0"); + var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); + using var console = new TestConsole(); + + UpdateBanner.RenderIfAvailable(Task.FromResult(info), waitFor: TimeSpan.FromSeconds(1), console: console); + + Assert.Contains("v1.4.2", console.Output, StringComparison.Ordinal); + } + [Fact] public void RenderIfAvailable_with_null_task_throws() {