The modal deliberately shows only a slice of the analytics; this hands off to TokenTracker's own local dashboard for the rest. The worker starts `tokentracker serve` on demand and returns the URL, the UI opens the browser. Three things the spawn has to get right: port 7680 is not free on Windows (Delivery Optimization holds [::]:7680) and serve does not fall back, so we scan 7680-7689 with a dual-stack bind probe; --no-open because the CLI would open the browser before the server answers; and the child is a cmd.exe shim, so shutdown kills the process tree. --no-sync keeps our no-cloud-sync rule.
165 lines
4.8 KiB
C#
165 lines
4.8 KiB
C#
using ClaudeDo.Worker.Usage.TokenTracker;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
|
|
|
public sealed class TokenTrackerServiceTests
|
|
{
|
|
private static readonly DateTime Now = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static (TokenTrackerService Service, FakeTokenTrackerClient Client) Build(DateTime? now = null)
|
|
{
|
|
var client = new FakeTokenTrackerClient();
|
|
var clock = now ?? Now;
|
|
return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenDashboardAsync_ReturnsTheUrlWhenTheCliIsInstalled()
|
|
{
|
|
var (service, client) = Build();
|
|
|
|
var dashboard = await service.OpenDashboardAsync();
|
|
|
|
Assert.True(dashboard.Ok);
|
|
Assert.Equal("http://127.0.0.1:7680/", dashboard.Url);
|
|
Assert.Equal(1, client.DashboardCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenDashboardAsync_MissingCli_ReportsTheProbeErrorWithoutSpawning()
|
|
{
|
|
var (service, client) = Build();
|
|
client.Probe = new TokenTrackerProbe(false, null, true, "v22.1.0", "'tokentracker' not found on PATH.");
|
|
|
|
var dashboard = await service.OpenDashboardAsync();
|
|
|
|
Assert.False(dashboard.Ok);
|
|
Assert.Null(dashboard.Url);
|
|
Assert.Equal("'tokentracker' not found on PATH.", dashboard.Error);
|
|
Assert.Equal(0, client.DashboardCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshAsync_StoresParsedExport()
|
|
{
|
|
var (service, client) = Build();
|
|
|
|
await service.RefreshAsync();
|
|
|
|
Assert.Equal(1, client.ExportCalls);
|
|
Assert.Equal(4, service.State.Export!.Sessions.Count);
|
|
Assert.Null(service.State.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshAsync_RequestsTheConfiguredWindow()
|
|
{
|
|
var (service, client) = Build();
|
|
|
|
await service.RefreshAsync();
|
|
|
|
Assert.Equal(DateOnly.FromDateTime(Now.ToLocalTime()), client.LastTo);
|
|
Assert.Equal(
|
|
DateOnly.FromDateTime(Now.ToLocalTime()).AddDays(-TokenTrackerService.WindowDays),
|
|
client.LastFrom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshAsync_Failure_KeepsLastGoodExport()
|
|
{
|
|
var (service, client) = Build();
|
|
await service.RefreshAsync();
|
|
|
|
client.ExportResult = new TokenTrackerRunResult(false, "", "tokentracker exited with code 1");
|
|
await service.RefreshAsync();
|
|
|
|
Assert.Equal(4, service.State.Export!.Sessions.Count);
|
|
Assert.Equal("tokentracker exited with code 1", service.State.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshAsync_UnparseableOutput_IsAnError()
|
|
{
|
|
var (service, client) = Build();
|
|
client.ExportResult = new TokenTrackerRunResult(true, "not json", null);
|
|
|
|
await service.RefreshAsync();
|
|
|
|
Assert.Null(service.State.Export);
|
|
Assert.NotNull(service.State.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshAsync_UnsupportedFormatVersion_IsAnErrorAndKeepsNoData()
|
|
{
|
|
var (service, client) = Build();
|
|
client.ExportResult = new TokenTrackerRunResult(true, TokenTrackerFixtures.UnsupportedVersionJson, null);
|
|
|
|
await service.RefreshAsync();
|
|
|
|
Assert.Null(service.State.Export);
|
|
Assert.Contains("99", service.State.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnsureFreshAsync_FreshExport_DoesNotFetchAgain()
|
|
{
|
|
var (service, client) = Build();
|
|
await service.RefreshAsync();
|
|
|
|
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
|
|
|
|
Assert.Equal(1, client.ExportCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnsureFreshAsync_NoExportYet_Fetches()
|
|
{
|
|
var (service, client) = Build();
|
|
|
|
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
|
|
|
|
Assert.Equal(1, client.ExportCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentRefresh_RunsTheCliOnce()
|
|
{
|
|
var (service, client) = Build();
|
|
client.BlockExport = true;
|
|
|
|
var first = service.RefreshAsync();
|
|
var second = service.RefreshAsync();
|
|
client.Release();
|
|
await Task.WhenAll(first, second);
|
|
|
|
Assert.Equal(1, client.ExportCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_CachesUntilForced()
|
|
{
|
|
var (service, client) = Build();
|
|
|
|
await service.ProbeAsync();
|
|
await service.ProbeAsync();
|
|
Assert.Equal(1, client.ProbeCalls);
|
|
|
|
await service.ProbeAsync(force: true);
|
|
Assert.Equal(2, client.ProbeCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InstallAsync_ReprobesAfterwards()
|
|
{
|
|
var (service, client) = Build();
|
|
await service.ProbeAsync();
|
|
|
|
var result = await service.InstallAsync();
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal(1, client.InstallCalls);
|
|
Assert.Equal(2, client.ProbeCalls);
|
|
}
|
|
}
|