Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Empty file added .entire/logs/entire.log
Empty file.
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ linters:
linters:
- errcheck
- noctx
- path: internal/
linters:
- errcheck
- path: redact/
linters:
- errcheck

issues:
max-issues-per-linter: 0
Expand Down
95 changes: 92 additions & 3 deletions cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,96 @@
package cli

import (
"bytes"
"context"
"errors"
"net/http"
"path/filepath"
"strings"
"testing"
"time"

"github.com/GrayCodeAI/trace/cli/auth"
"github.com/GrayCodeAI/trace/internal/entireclient/clusterdiscovery"
"github.com/GrayCodeAI/trace/internal/entireclient/contexts"
"github.com/GrayCodeAI/trace/internal/entireclient/tokenstore"
)

func strPtr(v string) *string { return &v }

// TestRunActivity_SilencesContextCanceled pins the codebase convention
// (clean.go, explain.go, explain_export.go) for Ctrl+C during the auth
// resolution: NewSilentError wraps the cancellation so cobra doesn't
// print "context canceled" at a user who just chose to stop.
//
// Pre-PR runActivity silenced *every* auth-resolution error under the
// "Not logged in" hint; that was wrong because real STS / network
// failures got mis-labeled. This PR surfaces real errors but has to
// keep the cancellation case silent.
func TestRunActivity_SilencesContextCanceled(t *testing.T) {
// No t.Parallel: SetResolveContextForAPIForTest mutates package-level
// auth state.
//
// Simulate the user hitting Ctrl+C during auth resolution: the
// cancellation surfaces from the discovery fetch, and runActivity must
// silence it rather than mislabel it "Not logged in".
t.Cleanup(auth.SetResolveContextForAPIForTest(t,
func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) {
return nil, context.Canceled
}))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut, false)
if err == nil {
t.Fatal("expected error when STS exchange is cancelled")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
var silent *SilentError
if !errors.As(err, &silent) {
t.Errorf("error = %v, want SilentError wrap so cobra suppresses output", err)
}
if errOut.Len() != 0 {
t.Errorf("errOut = %q, want empty (no 'Not logged in' hint on cancellation)", errOut.String())
}
}

// TestRunActivity_PrintsLoginHintOnNotLoggedIn pins the other half of
// the same branch: a missing keyring entry still produces the friendly
// hint and a SilentError so the raw "not logged in" string doesn't
// also print via cobra.
func TestRunActivity_PrintsLoginHintOnNotLoggedIn(t *testing.T) {
// No t.Parallel: SetResolveContextForAPIForTest mutates package-level
// auth state.
//
// Discovery selects a context whose keyring slot holds nothing, so the
// per-context provider reports ErrNotLoggedIn.
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
c := &contexts.Context{Name: "me@core", CoreURL: "https://core.example", Handle: "me", KeychainService: "kc:me"}
t.Cleanup(auth.SetResolveContextForAPIForTest(t,
func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) {
return c, nil
}))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut, false)
if err == nil {
t.Fatal("expected error when not logged in")
}
if !errors.Is(err, auth.ErrNotLoggedIn) {
t.Errorf("error chain missing ErrNotLoggedIn: %v", err)
}
var silent *SilentError
if !errors.As(err, &silent) {
t.Errorf("error = %v, want SilentError wrap", err)
}
wantHint := "Not logged in. Run 'entire login' to authenticate."
if got := errOut.String(); !strings.Contains(got, wantHint) {
t.Errorf("errOut = %q, want hint %q", got, wantHint)
}
}

func TestNormalizeAgentString(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down Expand Up @@ -46,10 +130,15 @@ func TestNormalizeAgentString(t *testing.T) {

func TestGroupCommitsByDay_SortsNewestFirst(t *testing.T) {
t.Parallel()

localDate := func(year int, month time.Month, day int) *string {
return strPtr(time.Date(year, month, day, 12, 0, 0, 0, time.Local).Format(time.RFC3339))
}

commits := []userCommit{
{CommitSHA: "aaa", CommitDate: strPtr("2026-01-10T12:00:00Z")},
{CommitSHA: "bbb", CommitDate: strPtr("2026-01-12T08:00:00Z")},
{CommitSHA: "ccc", CommitDate: strPtr("2026-01-11T15:00:00Z")},
{CommitSHA: "aaa", CommitDate: localDate(2026, time.January, 10)},
{CommitSHA: "bbb", CommitDate: localDate(2026, time.January, 12)},
{CommitSHA: "ccc", CommitDate: localDate(2026, time.January, 11)},
}
days := groupCommitsByDay(commits)

Expand Down
43 changes: 34 additions & 9 deletions cli/activity_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"unicode/utf8"
)

const testActivityAgentClaude = "claude"
const activityTestAgentClaude = "claude"

func TestUniqueCommitAgents_UsesAgentsSlice(t *testing.T) {
t.Parallel()
Expand All @@ -21,7 +21,7 @@ func TestUniqueCommitAgents_UsesAgentsSlice(t *testing.T) {
if len(agents) != 2 {
t.Fatalf("got %d agents, want 2", len(agents))
}
if agents[0] != testActivityAgentClaude || agents[1] != "gemini" {
if agents[0] != activityTestAgentClaude || agents[1] != "gemini" {
t.Errorf("got %v, want [claude gemini]", agents)
}
}
Expand All @@ -34,7 +34,7 @@ func TestUniqueCommitAgents_FallsBackToSingularAgent(t *testing.T) {
},
}
agents := uniqueCommitAgents(c)
if len(agents) != 1 || agents[0] != testActivityAgentClaude {
if len(agents) != 1 || agents[0] != activityTestAgentClaude {
t.Errorf("got %v, want [claude] (should fall back to Agent field)", agents)
}
}
Expand All @@ -56,8 +56,8 @@ func TestUniqueCommitAgents_Dedupes(t *testing.T) {
t.Parallel()
c := userCommit{
Checkpoints: []userCommitCheckpoint{
{Agent: testActivityAgentClaude, Agents: []string{"Claude Code"}},
{Agent: testActivityAgentClaude, Agents: []string{"Claude Code"}},
{Agent: activityTestAgentClaude, Agents: []string{"Claude Code"}},
{Agent: activityTestAgentClaude, Agents: []string{"Claude Code"}},
},
}
agents := uniqueCommitAgents(c)
Expand Down Expand Up @@ -152,7 +152,7 @@ func TestRenderCommitList_SingularPlural(t *testing.T) {
CommitMsg: strPtr("msg"),
RepoFullName: "org/repo",
FilesChanged: 1,
Checkpoints: []userCommitCheckpoint{{Agent: testActivityAgentClaude}},
Checkpoints: []userCommitCheckpoint{{Agent: activityTestAgentClaude}},
},
}},
}
Expand Down Expand Up @@ -228,10 +228,10 @@ func TestRenderContributionChart_MonthAxisWideWidth(t *testing.T) {
var buf bytes.Buffer
sty := activityStyles{width: 200}
hourly := []hourlyPoint{
{Date: "2026-04-01", Hour: 12, Value: 3, AgentID: testActivityAgentClaude},
{Date: "2026-04-01", Hour: 12, Value: 3, AgentID: activityTestAgentClaude},
}
repos := []repoContribution{
{Repo: "org/repo", Total: 1, Agents: map[string]int{testActivityAgentClaude: 1}},
{Repo: "org/repo", Total: 1, Agents: map[string]int{activityTestAgentClaude: 1}},
}

renderContributionChart(&buf, sty, hourly, repos)
Expand All @@ -254,7 +254,7 @@ func TestRenderRepoChart_LimitsToFive(t *testing.T) {
repos = append(repos, repoContribution{
Repo: strings.Repeat("r", i+1),
Total: 8 - i,
Agents: map[string]int{testActivityAgentClaude: 8 - i},
Agents: map[string]int{activityTestAgentClaude: 8 - i},
})
}

Expand All @@ -276,6 +276,31 @@ func TestRenderRepoChart_LimitsToFive(t *testing.T) {
}
}

func TestRenderRepoChart_UnicodeNameSafeTruncation(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
sty := activityStyles{width: 60}
// A repo name long enough to force truncation and full of multi-byte
// runes, so a byte-based slice would split a rune and emit invalid UTF-8.
repos := []repoContribution{
{
Repo: strings.Repeat("é", 40),
Total: 3,
Agents: map[string]int{activityTestAgentClaude: 3},
},
}

renderRepoChart(&buf, sty, repos)
out := buf.String()

if !utf8.ValidString(out) {
t.Fatal("rendered repo chart contains invalid UTF-8")
}
if !strings.Contains(out, "…") {
t.Error("expected the long repo name to be truncated with an ellipsis")
}
}

func TestPadOrTruncate(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
Loading