diff --git a/AIUsageMonitor.slnx b/AIUsageMonitor.slnx
index f4c2095..6450fd5 100644
--- a/AIUsageMonitor.slnx
+++ b/AIUsageMonitor.slnx
@@ -16,9 +16,11 @@
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d9b773f..26a478d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
+
diff --git a/README.md b/README.md
index 6d0c021..974d249 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,10 @@ Press `r` while `watch` is running to re-enter any of the four (Enter keeps the

+### Update checks
+
+Every command checks GitHub for a newer release once after it finishes (`watch` checks once before entering its refresh loop and keeps the notice pinned to the bottom of the view for the whole session). If a newer version is available, a one-line notice with the new version and a link to the release is printed — this never blocks or fails the command, and no data is sent beyond the standard GitHub API request for the latest release.
+
### WPF dashboard (Windows only)
> **⚠️ Work in progress — not ready for use yet.** The WPF project is still under active development; expect missing features and rough edges. Use the CLI (`aimon`) for now.
@@ -165,6 +169,10 @@ aimon <命令>

+### 更新检查
+
+每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。
+
### WPF 仪表盘(仅 Windows)
> **⚠️ 尚在开发中,暂不建议使用。** WPF 项目目前仍在积极开发,功能不完整,可能存在明显问题。请先使用 CLI(`aimon`)。
diff --git a/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj b/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj
index 233e1a0..cac86ba 100644
--- a/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj
+++ b/src/AIUsageMonitor.Cli/AIUsageMonitor.Cli.csproj
@@ -2,10 +2,12 @@
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs
index 4f2551f..802a697 100644
--- a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs
+++ b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs
@@ -1,6 +1,7 @@
using AIUsageMonitor.Cli.Rendering;
using AIUsageMonitor.Core.Models;
using AIUsageMonitor.Core.Services;
+using AIUsageMonitor.UpdateCheck;
using Spectre.Console;
using Spectre.Console.Rendering;
using System.CommandLine;
@@ -16,8 +17,9 @@ public static class WatchCommand
/// Creates the "watch" command with its options and action.
///
/// The data service used to retrieve usage data for the specified view.
+ /// The update checker used to show a persistent notice when a newer version is available.
/// A configured instance for continuously refreshing a usage view.
- public static Command Create(DataService dataService)
+ public static Command Create(DataService dataService, IUpdateChecker updateChecker)
{
var command = new Command("watch", "Continuously refresh a usage view at a fixed interval");
var viewOption = new Option("--view")
@@ -79,6 +81,14 @@ public static Command Create(DataService dataService)
// reusing the pre-prompt snapshot here would overwrite them with their stale values.
saved = LimitsSettingsStore.Load();
+ // Re-fetch pinned to the now-resolved reset times: the anchor-less fetch above can
+ // report a different TotalTokens than the pinned window used for every render below
+ // (most visibly for the week window, whose 7-day span makes the two diverge a lot),
+ // so deriving the token limit from the anchor-less totals would make the very next
+ // render show a different percentage than the one just entered.
+ lastSessionWindow = dataService.GetCurrentSessionWindow(effectiveSessionResetAt);
+ lastWeekWindow = dataService.GetWeekWindow(effectiveWeekResetAt);
+
if (!LimitsAnchors.ResolveTokenLimit(
sessionTokenProgressArg, saved.SessionTokenLimit, lastSessionWindow.TotalTokens, "Session",
out effectiveSessionTokenLimit, out error)
@@ -99,22 +109,32 @@ public static Command Create(DataService dataService)
var recalibrationEnabled = view == "limits" && !Console.IsInputRedirected;
- IRenderable BuildCurrent(IProgress? progress = null) => view switch
+ // Checked once, up front - watch is a long-running loop, so the notice needs to be
+ // visible while it runs rather than only after it exits.
+ var updateInfo = await updateChecker.CheckForUpdateAsync(ct);
+ var updateNotice = UpdateNotice.BuildRenderable(updateInfo);
+
+ IRenderable BuildCurrent(IProgress? progress = null)
{
- "today" => new Rows(
- SpectreRenderer.BuildDailySummary(
- dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress)
- ?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today))),
- new Rule().RuleStyle("grey"),
- SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, progress).HourlyTrend)),
- "week" => SpectreRenderer.BuildPeriodSummary(
- dataService.GetPeriodSummary(DateOnly.FromDateTime(DateTime.Today).AddDays(-6), DateOnly.FromDateTime(DateTime.Today), progress)),
- "models" => SpectreRenderer.BuildModelDistribution(dataService.GetModelDistribution(progress)),
- "sessions" => SpectreRenderer.BuildSessionStats(dataService.GetSessionStats(progress)),
- "hours" => SpectreRenderer.BuildHourlyActivity(dataService.GetHourlyActivity(progress)),
- "limits" => BuildLimits(progress),
- _ => new Markup($"[red]Unknown view: {view}. Use today|week|models|sessions|hours|limits.[/]")
- };
+ IRenderable content = view switch
+ {
+ "today" => new Rows(
+ SpectreRenderer.BuildDailySummary(
+ dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress)
+ ?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today))),
+ new Rule().RuleStyle("grey"),
+ SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, progress).HourlyTrend)),
+ "week" => SpectreRenderer.BuildPeriodSummary(
+ dataService.GetPeriodSummary(DateOnly.FromDateTime(DateTime.Today).AddDays(-6), DateOnly.FromDateTime(DateTime.Today), progress)),
+ "models" => SpectreRenderer.BuildModelDistribution(dataService.GetModelDistribution(progress)),
+ "sessions" => SpectreRenderer.BuildSessionStats(dataService.GetSessionStats(progress)),
+ "hours" => SpectreRenderer.BuildHourlyActivity(dataService.GetHourlyActivity(progress)),
+ "limits" => BuildLimits(progress),
+ _ => new Markup($"[red]Unknown view: {view}. Use today|week|models|sessions|hours|limits.[/]")
+ };
+
+ return WithUpdateNotice(content);
+ }
IRenderable BuildLimits(IProgress? progress)
{
@@ -131,8 +151,11 @@ IRenderable BuildLimits(IProgress? progress)
ClearScreen();
+ IRenderable WithUpdateNotice(IRenderable content) =>
+ updateNotice is null ? content : new Rows(content, updateNotice);
+
var current = view == "limits"
- ? SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionTokenLimit, effectiveWeekTokenLimit, recalibrationEnabled, effectiveSessionResetAt is not null)
+ ? WithUpdateNotice(SpectreRenderer.BuildUsageLimits(lastSessionWindow!, lastWeekWindow!, effectiveSessionTokenLimit, effectiveWeekTokenLimit, recalibrationEnabled, effectiveSessionResetAt is not null))
: ProgressReporter.Run("Loading usage data...", BuildCurrent);
while (!ct.IsCancellationRequested)
diff --git a/src/AIUsageMonitor.Cli/Program.cs b/src/AIUsageMonitor.Cli/Program.cs
index 44cb2b5..b6b77e7 100644
--- a/src/AIUsageMonitor.Cli/Program.cs
+++ b/src/AIUsageMonitor.Cli/Program.cs
@@ -1,6 +1,8 @@
using System.CommandLine;
+using AIUsageMonitor.Cli;
using AIUsageMonitor.Cli.Commands;
using AIUsageMonitor.Core.Services;
+using AIUsageMonitor.UpdateCheck;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -11,9 +13,11 @@
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddClaudeUsageCore();
+ builder.Services.AddHttpClient();
var host = builder.Build();
var dataService = host.Services.GetRequiredService();
+ var updateChecker = host.Services.GetRequiredService();
var rootCommand = new RootCommand("aimon - AI Usage Monitor");
@@ -24,9 +28,22 @@
rootCommand.Subcommands.Add(SessionsCommand.Create(dataService));
rootCommand.Subcommands.Add(HoursCommand.Create(dataService));
rootCommand.Subcommands.Add(ExportCommand.Create(dataService));
- rootCommand.Subcommands.Add(WatchCommand.Create(dataService));
+ var watchCommand = WatchCommand.Create(dataService, updateChecker);
+ rootCommand.Subcommands.Add(watchCommand);
- return await rootCommand.Parse(args).InvokeAsync();
+ var parseResult = rootCommand.Parse(args);
+ var isWatch = parseResult.CommandResult.Command == watchCommand;
+ var exitCode = await parseResult.InvokeAsync();
+
+ // watch runs its own update check up front (it's a long-running loop, so the notice needs to
+ // show while it's running, not after it exits) - every other command checks once here, after
+ // its own output, so the check never delays the command's actual result.
+ if (!isWatch)
+ {
+ await UpdateNotice.PrintIfAvailableAsync(updateChecker);
+ }
+
+ return exitCode;
}
catch (Exception ex)
{
diff --git a/src/AIUsageMonitor.Cli/UpdateNotice.cs b/src/AIUsageMonitor.Cli/UpdateNotice.cs
new file mode 100644
index 0000000..4bf0b77
--- /dev/null
+++ b/src/AIUsageMonitor.Cli/UpdateNotice.cs
@@ -0,0 +1,40 @@
+using AIUsageMonitor.UpdateCheck;
+using Spectre.Console;
+
+namespace AIUsageMonitor.Cli;
+
+///
+/// Prints a notice pointing to the latest GitHub release when a newer version than the one
+/// currently running is available.
+///
+public static class UpdateNotice
+{
+ ///
+ /// Checks for an update and, if one is available, prints a notice.
+ ///
+ /// The update checker to query.
+ public static async Task PrintIfAvailableAsync(IUpdateChecker updateChecker)
+ {
+ var result = await updateChecker.CheckForUpdateAsync();
+ if (result.IsUpdateAvailable)
+ {
+ // Written to stderr, not stdout, so it never mixes into piped command output (e.g.
+ // `aimon export --format json` writing JSON to stdout for a script to consume).
+ var errorConsole = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(Console.Error) });
+ errorConsole.MarkupLine(
+ $"[yellow]A new version ({result.LatestVersion}) of aimon is available. Download it at {result.ReleaseUrl}[/]");
+ }
+ }
+
+ ///
+ /// Builds the update notice as a renderable line, for embedding into a continuously refreshed view.
+ ///
+ /// The update check result to render.
+ /// A markup line if an update is available; otherwise .
+ public static Spectre.Console.Rendering.IRenderable? BuildRenderable(UpdateCheckResult? result)
+ {
+ return result is { IsUpdateAvailable: true }
+ ? new Markup($"[yellow]A new version ({result.LatestVersion}) of aimon is available. Download it at {result.ReleaseUrl}[/]")
+ : null;
+ }
+}
diff --git a/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj b/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj
new file mode 100644
index 0000000..051ff28
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/AIUsageMonitor.UpdateCheck.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+ net10.0
+
+
+
diff --git a/src/AIUsageMonitor.UpdateCheck/AppVersion.cs b/src/AIUsageMonitor.UpdateCheck/AppVersion.cs
new file mode 100644
index 0000000..c913d7b
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/AppVersion.cs
@@ -0,0 +1,23 @@
+using System.Reflection;
+
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Reads the running application's version, as embedded by MinVer at build time.
+///
+public static class AppVersion
+{
+ ///
+ /// Gets the current application version, or if it could not be determined
+ /// (e.g. running from an unpublished build with no informational version attribute).
+ ///
+ /// The current semantic version, with any MinVer commit-hash suffix stripped.
+ public static Version? GetCurrent()
+ {
+ var informationalVersion = Assembly.GetEntryAssembly()
+ ?.GetCustomAttribute()
+ ?.InformationalVersion;
+
+ return VersionParser.Parse(informationalVersion);
+ }
+}
\ No newline at end of file
diff --git a/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs b/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs
new file mode 100644
index 0000000..78d15cb
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/GitHubReleaseResponse.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Represents the subset of GitHub's "get the latest release" API response used to determine
+/// the latest published version.
+///
+public sealed class GitHubReleaseResponse
+{
+ ///
+ /// Gets or sets the release's git tag name, e.g. v1.2.3.
+ ///
+ [JsonPropertyName("tag_name")]
+ public string? TagName
+ {
+ get; set;
+ }
+}
\ No newline at end of file
diff --git a/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs b/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs
new file mode 100644
index 0000000..2c472b9
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/IUpdateChecker.cs
@@ -0,0 +1,26 @@
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Checks GitHub for a newer published release than the one currently running.
+///
+public interface IUpdateChecker
+{
+ ///
+ /// Checks whether a newer release than the current one is available on GitHub.
+ ///
+ /// A token to cancel the check.
+ ///
+ /// The check result. Any network, timeout, or parsing failure is treated as "no update
+ /// available" rather than propagated, since this check must never break the command it runs
+ /// alongside.
+ ///
+ Task CheckForUpdateAsync(CancellationToken cancellationToken = default);
+}
+
+///
+/// Represents the outcome of an check.
+///
+/// Whether a newer release than the current one was found.
+/// The latest published version, if it could be determined.
+/// The page a user should visit to download the latest release.
+public sealed record UpdateCheckResult(bool IsUpdateAvailable, string? LatestVersion, string ReleaseUrl);
\ No newline at end of file
diff --git a/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs b/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs
new file mode 100644
index 0000000..99a1aa8
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/UpdateCheckJsonContext.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Represents the JSON serialization context used to deserialize GitHub release API responses
+/// without reflection.
+///
+[JsonSerializable(typeof(GitHubReleaseResponse))]
+public sealed partial class UpdateCheckJsonContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs b/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs
new file mode 100644
index 0000000..c98bebc
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/UpdateChecker.cs
@@ -0,0 +1,78 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Checks GitHub's "latest release" API for a newer published version of the application.
+///
+public sealed class UpdateChecker : IUpdateChecker
+{
+ ///
+ /// The GitHub page users should visit to download the latest release.
+ ///
+ public const string ReleaseUrl = $"https://github.com/{RepositoryOwner}/{RepositoryName}/releases/latest";
+
+ private const string ReleasesApiUrl = $"https://api.github.com/repos/{RepositoryOwner}/{RepositoryName}/releases/latest";
+ private const string RepositoryName = "AIUsageMonitor";
+ private const string RepositoryOwner = "coldhighsun";
+ private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP client used to call the GitHub API.
+ /// The logger used to record failed update checks.
+ public UpdateChecker(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default)
+ {
+ var noUpdate = new UpdateCheckResult(false, null, ReleaseUrl);
+
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(RequestTimeout);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesApiUrl);
+ request.Headers.UserAgent.Add(new ProductInfoHeaderValue(RepositoryName, "1.0"));
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+
+ using var response = await _httpClient.SendAsync(request, cts.Token);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Update check failed with status {StatusCode}", response.StatusCode);
+ return noUpdate;
+ }
+
+ var release = await response.Content.ReadFromJsonAsync(
+ UpdateCheckJsonContext.Default.GitHubReleaseResponse, cts.Token);
+
+ var latestVersion = VersionParser.Parse(release?.TagName);
+ var currentVersion = AppVersion.GetCurrent();
+
+ if (latestVersion is null || currentVersion is null)
+ {
+ return noUpdate;
+ }
+
+ return latestVersion > currentVersion
+ ? new UpdateCheckResult(true, latestVersion.ToString(), ReleaseUrl)
+ : noUpdate;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "Update check could not reach GitHub");
+ return noUpdate;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AIUsageMonitor.UpdateCheck/VersionParser.cs b/src/AIUsageMonitor.UpdateCheck/VersionParser.cs
new file mode 100644
index 0000000..2ee1f17
--- /dev/null
+++ b/src/AIUsageMonitor.UpdateCheck/VersionParser.cs
@@ -0,0 +1,30 @@
+using System.Text.RegularExpressions;
+
+namespace AIUsageMonitor.UpdateCheck;
+
+///
+/// Parses loose semantic-version strings (an optional leading v, an optional
+/// pre-release/build-metadata suffix such as MinVer's +commitsha) into a plain
+/// usable for comparison.
+///
+internal static partial class VersionParser
+{
+ ///
+ /// Parses the leading major.minor.patch portion of .
+ ///
+ /// The version string to parse, e.g. v1.2.3 or 1.2.3-preview.1+abcdef.
+ /// The parsed version, or if is not a recognizable version.
+ public static Version? Parse(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ var match = CoreVersionRegex().Match(value);
+ return match.Success && Version.TryParse(match.Value, out var version) ? version : null;
+ }
+
+ [GeneratedRegex(@"\d+\.\d+\.\d+")]
+ private static partial Regex CoreVersionRegex();
+}
\ No newline at end of file
diff --git a/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj b/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj
new file mode 100644
index 0000000..eb06bd3
--- /dev/null
+++ b/tests/AIUsageMonitor.UpdateCheck.Tests/AIUsageMonitor.UpdateCheck.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs b/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs
new file mode 100644
index 0000000..91f15ae
--- /dev/null
+++ b/tests/AIUsageMonitor.UpdateCheck.Tests/UpdateCheckerTests.cs
@@ -0,0 +1,79 @@
+using System.Net;
+using AIUsageMonitor.UpdateCheck;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AIUsageMonitor.UpdateCheck.Tests;
+
+public class UpdateCheckerTests
+{
+ [Fact]
+ public async Task CheckForUpdateAsync_NewerTagPublished_ReturnsUpdateAvailable()
+ {
+ var handler = new StubHttpMessageHandler(
+ HttpStatusCode.OK, """{"tag_name": "v99.0.0"}""");
+ var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance);
+
+ var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("99.0.0", result.LatestVersion);
+ Assert.Equal(AIUsageMonitor.UpdateCheck.UpdateChecker.ReleaseUrl, result.ReleaseUrl);
+ }
+
+ [Fact]
+ public async Task CheckForUpdateAsync_NoCurrentVersionKnown_ReturnsNoUpdateAvailable()
+ {
+ // AppVersion.GetCurrent() returns null in the test host (no AssemblyInformationalVersion
+ // attribute set by MinVer during a test run), so a real tag always compares as "no update".
+ var handler = new StubHttpMessageHandler(
+ HttpStatusCode.OK, """{"tag_name": "v1.0.0"}""");
+ var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance);
+
+ var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsUpdateAvailable);
+ Assert.Null(result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task CheckForUpdateAsync_HttpRequestFails_ReturnsNoUpdateAvailable()
+ {
+ var handler = new StubHttpMessageHandler(exception: new HttpRequestException("network down"));
+ var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance);
+
+ var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsUpdateAvailable);
+ Assert.Null(result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task CheckForUpdateAsync_NonSuccessStatusCode_ReturnsNoUpdateAvailable()
+ {
+ var handler = new StubHttpMessageHandler(HttpStatusCode.ServiceUnavailable, string.Empty);
+ var checker = new UpdateChecker(new HttpClient(handler), NullLogger.Instance);
+
+ var result = await checker.CheckForUpdateAsync(TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsUpdateAvailable);
+ }
+
+ private sealed class StubHttpMessageHandler(
+ HttpStatusCode statusCode = HttpStatusCode.OK, string content = "", Exception? exception = null)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (exception is not null)
+ {
+ throw exception;
+ }
+
+ return Task.FromResult(new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(content)
+ });
+ }
+ }
+}