f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
345 lines
11 KiB
Go
345 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/activity"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/pricing"
|
|
"go.kenn.io/agentsview/internal/server"
|
|
"go.kenn.io/agentsview/internal/sync"
|
|
)
|
|
|
|
func TestNewActivityCommand_RegistersReport(t *testing.T) {
|
|
cmd := newActivityCommand()
|
|
assert.Equal(t, "activity", cmd.Name())
|
|
sub, _, err := cmd.Find([]string{"report"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "report", sub.Name())
|
|
}
|
|
|
|
func TestActivityReportCommand_Flags(t *testing.T) {
|
|
cmd := newActivityReportCommand()
|
|
for _, name := range []string{
|
|
"preset", "date", "from", "to", "timezone",
|
|
"bucket", "project", "agent", "machine", "json", "no-sync",
|
|
"offline",
|
|
} {
|
|
assert.NotNilf(t, cmd.Flags().Lookup(name), "flag --%s must exist", name)
|
|
}
|
|
}
|
|
|
|
func TestActivityReportCommand_HelpText(t *testing.T) {
|
|
cmd := newActivityReportCommand()
|
|
var buf bytes.Buffer
|
|
cmd.SetOut(&buf)
|
|
cmd.SetArgs([]string{"--help"})
|
|
require.NoError(t, cmd.Execute())
|
|
out := buf.String()
|
|
assert.Contains(t, out, "--preset")
|
|
assert.Contains(t, out, "--json")
|
|
}
|
|
|
|
func TestResolveActivityReport_BadBucket(t *testing.T) {
|
|
d := newTestDB(t)
|
|
_, err := resolveActivityReport(ActivityReportConfig{
|
|
Preset: "day", Date: "2026-06-16", Timezone: "UTC", Bucket: "2h",
|
|
}, d)
|
|
require.Error(t, err, "off-allow-list bucket is rejected before query")
|
|
}
|
|
|
|
func TestResolveActivityReport_JSONShape(t *testing.T) {
|
|
d := newTestDB(t)
|
|
r, err := resolveActivityReport(ActivityReportConfig{
|
|
Preset: "day", Date: "2026-06-16", Timezone: "UTC",
|
|
}, d)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "minute", r.BucketUnit)
|
|
assert.Equal(t, "2026-06-16T00:00:00Z", r.RangeStart)
|
|
}
|
|
|
|
func TestActivityReport_UsesDiscoveredDaemon(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
|
|
var gotQuery url.Values
|
|
ts := daemonRouteTestServer(t, map[string]http.HandlerFunc{
|
|
"/api/v1/activity/report": func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, `{
|
|
"timezone":"UTC",
|
|
"range_start":"2026-06-16T00:00:00Z",
|
|
"range_end":"2026-06-17T00:00:00Z",
|
|
"bucket_unit":"1h",
|
|
"bucket_seconds":3600,
|
|
"bucket_count":24,
|
|
"effective_end":"2026-06-17T00:00:00Z",
|
|
"elapsed_bucket_count":24,
|
|
"buckets":[],
|
|
"peak":{"agents":0},
|
|
"totals":{"sessions":3},
|
|
"by_project":[],
|
|
"by_model":[],
|
|
"by_agent":[],
|
|
"by_session":[],
|
|
"intervals":[]
|
|
}`)
|
|
},
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runActivityReport(ActivityReportConfig{
|
|
JSON: true, Preset: "day",
|
|
Date: "2026-06-16", Timezone: "UTC",
|
|
})
|
|
})
|
|
assert.Equal(t, "day", gotQuery.Get("preset"))
|
|
assert.Equal(t, "2026-06-16", gotQuery.Get("date"))
|
|
assert.Equal(t, "UTC", gotQuery.Get("timezone"))
|
|
|
|
var payload activity.Report
|
|
require.NoError(t, json.Unmarshal([]byte(out), &payload))
|
|
assert.Equal(t, "UTC", payload.Timezone)
|
|
assert.Equal(t, 3, payload.Totals.Sessions)
|
|
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
|
|
}
|
|
|
|
// mustLocation loads a named time zone, failing the test if it is unavailable.
|
|
func mustLocation(t *testing.T, name string) *time.Location {
|
|
t.Helper()
|
|
loc, err := time.LoadLocation(name)
|
|
require.NoError(t, err)
|
|
return loc
|
|
}
|
|
|
|
func TestFmtRangeBound_RendersInTimezone(t *testing.T) {
|
|
chicago := mustLocation(t, "America/Chicago")
|
|
// 05:00Z is local midnight in Chicago (CDT, UTC-5) in June.
|
|
assert.Equal(t, "2026-06-16", fmtRangeBound("2026-06-16T05:00:00Z", chicago))
|
|
// UTC midnight renders date-only in UTC.
|
|
assert.Equal(t, "2026-06-16", fmtRangeBound("2026-06-16T00:00:00Z", time.UTC))
|
|
// A non-midnight bound keeps the local time component.
|
|
assert.Equal(t, "2026-06-16 12:30", fmtRangeBound("2026-06-16T12:30:00Z", time.UTC))
|
|
}
|
|
|
|
func TestFmtInstant_NilAndTimezone(t *testing.T) {
|
|
assert.Equal(t, "—", fmtInstant(nil, time.UTC))
|
|
chicago := mustLocation(t, "America/Chicago")
|
|
ts := "2026-06-16T05:30:00Z" // 00:30 CDT
|
|
assert.Equal(t, "2026-06-16 00:30", fmtInstant(&ts, chicago))
|
|
}
|
|
|
|
// TestPrintActivityReport_SanitizesSessionDerivedStrings confirms the
|
|
// human-readable activity output strips control/escape bytes from
|
|
// session-derived fields (breakdown keys and session title/project/agent), so
|
|
// crafted imported or synced metadata cannot drive terminal escape sequences.
|
|
// JSON output is left untouched and is covered separately.
|
|
func TestPrintActivityReport_SanitizesSessionDerivedStrings(t *testing.T) {
|
|
mins := 1.0
|
|
// OSC title-set + BEL, then a bare CR overwrite: all control bytes stripped.
|
|
evil := "\x1b]0;pwned\x07safe\rEVIL"
|
|
r := activity.Report{
|
|
Timezone: "UTC",
|
|
RangeStart: "2026-06-16T00:00:00Z",
|
|
RangeEnd: "2026-06-17T00:00:00Z",
|
|
BucketUnit: "minute",
|
|
ByProject: []activity.KeyMinutes{{Key: evil, AgentMinutes: 1}},
|
|
BySession: []activity.SessionRow{{
|
|
SessionID: "s1",
|
|
Title: evil,
|
|
Project: evil,
|
|
Agent: evil,
|
|
AgentMinutes: &mins,
|
|
}},
|
|
}
|
|
|
|
out := captureStdout(t, func() { printActivityReport(r) })
|
|
|
|
assert.NotContains(t, out, "\x1b", "ESC must be stripped from output")
|
|
assert.NotContains(t, out, "\x07", "BEL must be stripped from output")
|
|
assert.NotContains(t, out, "\r", "bare CR must be stripped from output")
|
|
assert.Contains(t, out, "safeEVIL",
|
|
"printable text survives once control bytes are removed")
|
|
}
|
|
|
|
// fallbackPricedModel returns a model pattern from the offline fallback table
|
|
// that carries a non-zero output rate, so seeding it prices output tokens.
|
|
func fallbackPricedModel(t *testing.T) string {
|
|
t.Helper()
|
|
for _, p := range pricing.FallbackPricing() {
|
|
if p.OutputPerMTok > 0 {
|
|
return p.ModelPattern
|
|
}
|
|
}
|
|
require.FailNow(t, "no fallback model with a non-zero output rate")
|
|
return ""
|
|
}
|
|
|
|
// TestResolveActivityReport_PricesFreshDBUsage proves the pricing fix: a fresh
|
|
// DB holding unpriced token usage is priced because resolveActivityReportPriced
|
|
// seeds the fallback rates before resolving, exactly as runActivityReport does,
|
|
// so the report's cost is non-zero.
|
|
func TestResolveActivityReport_PricesFreshDBUsage(t *testing.T) {
|
|
d := newTestDB(t)
|
|
model := fallbackPricedModel(t)
|
|
|
|
started := "2026-06-15T10:00:00Z"
|
|
ended := "2026-06-15T10:05:00Z"
|
|
usage, err := json.Marshal(map[string]int{"input_tokens": 100, "output_tokens": 500})
|
|
require.NoError(t, err)
|
|
first := "first message"
|
|
_, err = d.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: db.Session{
|
|
ID: "cost-1", Project: "alpha", Machine: "local", Agent: "claude",
|
|
StartedAt: &started, EndedAt: &ended, CreatedAt: started,
|
|
FirstMessage: &first, MessageCount: 2, UserMessageCount: 1,
|
|
RelationshipType: "root", DataVersion: 1,
|
|
},
|
|
Messages: []db.Message{
|
|
{SessionID: "cost-1", Ordinal: 0, Role: "user", Content: "u",
|
|
Timestamp: started, ContentLength: 1},
|
|
{SessionID: "cost-1", Ordinal: 1, Role: "assistant", Content: "a",
|
|
Timestamp: ended, ContentLength: 1, Model: model,
|
|
TokenUsage: usage, OutputTokens: 500, HasOutputTokens: true},
|
|
},
|
|
DataVersion: 1, ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
// resolveActivityReportPriced seeds fallback pricing (Offline => no network)
|
|
// exactly as runActivityReport does, so removing that seeding fails here.
|
|
r, err := resolveActivityReportPriced(ActivityReportConfig{
|
|
Preset: "day", Date: "2026-06-15", Timezone: "UTC", Offline: true,
|
|
}, d, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens)
|
|
assert.Greater(t, r.Totals.Cost, 0.0,
|
|
"resolveActivityReportPriced must seed fallback pricing for fresh-DB usage")
|
|
}
|
|
|
|
func TestActivityReportJSONMatchesHTTPExportMetadata(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
dbPath := filepath.Join(dataDir, "sessions.db")
|
|
database := dbtest.OpenTestDBAt(t, dbPath)
|
|
fallbackModel := fallbackPricedModel(t)
|
|
require.NoError(t, seedFallbackPricing(database))
|
|
seedUsageDailyExportMetadataFixture(t, database, fallbackModel)
|
|
|
|
cliOut := captureStdout(t, func() {
|
|
runActivityReport(ActivityReportConfig{
|
|
JSON: true, Preset: "day", Date: "2026-06-01",
|
|
Timezone: "UTC", Offline: true, NoSync: true,
|
|
})
|
|
})
|
|
var cliReport activity.Report
|
|
require.NoError(t, json.Unmarshal([]byte(cliOut), &cliReport))
|
|
|
|
srv := server.New(config.Config{
|
|
Host: "127.0.0.1", Port: 0, DataDir: dataDir, DBPath: dbPath,
|
|
WriteTimeout: 30 * time.Second,
|
|
}, database, sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {dataDir},
|
|
},
|
|
Machine: "test",
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"http://127.0.0.1:0/api/v1/activity/report?"+
|
|
url.Values{
|
|
"preset": {"day"},
|
|
"date": {"2026-06-01"},
|
|
"timezone": {"UTC"},
|
|
}.Encode(), nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var httpReport activity.Report
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&httpReport))
|
|
|
|
assert.Equal(t, export.ActivityReportSchemaVersion,
|
|
cliReport.SchemaVersion)
|
|
assert.Equal(t, cliReport.SchemaVersion, httpReport.SchemaVersion)
|
|
require.NotNil(t, cliReport.Pricing)
|
|
require.NotNil(t, httpReport.Pricing)
|
|
assert.Contains(t, cliReport.Pricing.Models, "gpt-5.1")
|
|
assert.Contains(t, cliReport.Pricing.Models, fallbackModel)
|
|
assert.Equal(t, cliReport.Pricing.Models, httpReport.Pricing.Models)
|
|
require.Len(t, cliReport.Projects, 1)
|
|
require.Len(t, httpReport.Projects, 1)
|
|
for key, project := range cliReport.Projects {
|
|
assert.NotContains(t, key, "shared-project")
|
|
assert.Equal(t, "shared-project", project.DisplayLabel)
|
|
}
|
|
assert.Equal(t, cliReport.Projects, httpReport.Projects)
|
|
assert.Equal(t, "UTC", cliReport.Timezone)
|
|
assert.Equal(t, cliReport.Timezone, httpReport.Timezone)
|
|
assert.Equal(t, cliReport.Totals.Sessions, httpReport.Totals.Sessions)
|
|
assert.Equal(t, cliReport.Totals.OutputTokens,
|
|
httpReport.Totals.OutputTokens)
|
|
assert.NotEmpty(t, cliReport.Buckets)
|
|
assert.Equal(t, len(cliReport.BySession), len(httpReport.BySession))
|
|
}
|
|
|
|
func TestRunActivityReportOfflineUsesReadOnlyDBWhenWriteLockHeld(t *testing.T) {
|
|
dataDir := setupGoldenStatsDataDir(t)
|
|
|
|
lock, err := acquireWriteOwnerLock(context.Background(), dataDir)
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, lock.Close()) }()
|
|
|
|
out := captureStdout(t, func() {
|
|
runActivityReport(ActivityReportConfig{
|
|
Preset: "day",
|
|
Date: "2026-04-04",
|
|
Timezone: "UTC",
|
|
Offline: true,
|
|
})
|
|
})
|
|
|
|
assert.Contains(t, out, "Activity 2026-04-04 to 2026-04-05")
|
|
assert.Contains(t, out, "Sessions")
|
|
}
|
|
|
|
func TestActivityReportGolden(t *testing.T) {
|
|
setupExportGoldenDataDir(t)
|
|
oldNow := activityReportNow
|
|
activityReportNow = func() time.Time { return goldenFixtureNow }
|
|
t.Cleanup(func() { activityReportNow = oldNow })
|
|
|
|
cmd := newRootCommand()
|
|
cmd.SetArgs([]string{
|
|
"activity", "report",
|
|
"--json",
|
|
"--preset", "custom",
|
|
"--from", "2026-07-03T10:00:00Z",
|
|
"--to", "2026-07-03T13:00:00Z",
|
|
"--timezone", "UTC",
|
|
"--bucket", "1h",
|
|
"--offline",
|
|
"--no-sync",
|
|
})
|
|
var err error
|
|
stdout := captureStdout(t, func() {
|
|
_, err = cmd.ExecuteC()
|
|
})
|
|
require.NoError(t, err, "activity report json golden command")
|
|
|
|
assertGoldenBytes(t, "activity_report_v1.json", []byte(stdout))
|
|
}
|