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() {