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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions cmd/entire/cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,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
109 changes: 105 additions & 4 deletions cmd/entire/cli/auth/store.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
package auth

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/zalando/go-keyring"
)

const keyringService = "entire-cli"
const (
keyringService = "entire-cli"
testAuthStoreFileEnv = "ENTIRE_TEST_AUTH_STORE_FILE"
)

// Store manages CLI authentication tokens in the OS keyring.
// Store manages CLI authentication tokens. Production uses the OS keyring;
// subprocess tests can opt into a file-backed store with testAuthStoreFileEnv.
type Store struct {
service string
service string
testStoreFile string
}

// NewStore returns a Store backed by the system keyring.
func NewStore() *Store {
return &Store{service: keyringService}
return &Store{
service: keyringService,
testStoreFile: os.Getenv(testAuthStoreFileEnv),
}
}

// NewStoreWithService returns a Store with a custom keyring service name (for testing).
Expand All @@ -33,6 +44,10 @@ func (s *Store) SaveToken(baseURL, token string) error {
return errors.New("refusing to save empty token")
}

if s.testStoreFile != "" {
return s.saveTokenToFile(baseURL, token)
}

if err := keyring.Set(s.service, baseURL, token); err != nil {
return fmt.Errorf("save token to keyring: %w", err)
}
Expand All @@ -43,6 +58,10 @@ func (s *Store) SaveToken(baseURL, token string) error {
// GetToken retrieves a stored token for the given base URL.
// Returns an empty string (and no error) if no token is stored.
func (s *Store) GetToken(baseURL string) (string, error) {
if s.testStoreFile != "" {
return s.getTokenFromFile(baseURL)
}

token, err := keyring.Get(s.service, baseURL)
if errors.Is(err, keyring.ErrNotFound) {
return "", nil
Expand All @@ -56,6 +75,10 @@ func (s *Store) GetToken(baseURL string) (string, error) {

// DeleteToken removes a stored token for the given base URL.
func (s *Store) DeleteToken(baseURL string) error {
if s.testStoreFile != "" {
return s.deleteTokenFromFile(baseURL)
}

err := keyring.Delete(s.service, baseURL)
if errors.Is(err, keyring.ErrNotFound) {
return nil
Expand All @@ -72,3 +95,81 @@ func LookupCurrentToken() (string, error) {
store := NewStore()
return store.GetToken(api.BaseURL())
}

func (s *Store) saveTokenToFile(baseURL, token string) error {
Comment thread
Soph marked this conversation as resolved.
Outdated
tokens, err := s.readTokenFile()
if err != nil {
return err
}

serviceTokens := tokens[s.service]
if serviceTokens == nil {
serviceTokens = make(map[string]string)
tokens[s.service] = serviceTokens
}
serviceTokens[baseURL] = token

if err := s.writeTokenFile(tokens); err != nil {
return err
}
return nil
}

func (s *Store) getTokenFromFile(baseURL string) (string, error) {
tokens, err := s.readTokenFile()
if err != nil {
return "", err
}
return tokens[s.service][baseURL], nil
}

func (s *Store) deleteTokenFromFile(baseURL string) error {
tokens, err := s.readTokenFile()
if err != nil {
return err
}
if serviceTokens := tokens[s.service]; serviceTokens != nil {
delete(serviceTokens, baseURL)
if len(serviceTokens) == 0 {
delete(tokens, s.service)
}
}
return s.writeTokenFile(tokens)
}

func (s *Store) readTokenFile() (map[string]map[string]string, error) {
data, err := os.ReadFile(s.testStoreFile)
if errors.Is(err, os.ErrNotExist) {
return make(map[string]map[string]string), nil
}
if err != nil {
return nil, fmt.Errorf("read test auth store: %w", err)
}
if len(data) == 0 {
return make(map[string]map[string]string), nil
}

var tokens map[string]map[string]string
if err := json.Unmarshal(data, &tokens); err != nil {
return nil, fmt.Errorf("parse test auth store: %w", err)
}
if tokens == nil {
tokens = make(map[string]map[string]string)
}
return tokens, nil
}

func (s *Store) writeTokenFile(tokens map[string]map[string]string) error {
if err := os.MkdirAll(filepath.Dir(s.testStoreFile), 0o700); err != nil {
return fmt.Errorf("create test auth store directory: %w", err)
}

data, err := json.Marshal(tokens)
if err != nil {
return fmt.Errorf("marshal test auth store: %w", err)
}
if err := os.WriteFile(s.testStoreFile, data, 0o600); err != nil {
return fmt.Errorf("write test auth store: %w", err)
}
Comment thread
khaong marked this conversation as resolved.
return nil
}
28 changes: 28 additions & 0 deletions cmd/entire/cli/auth/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"os"
"path/filepath"
"testing"

"github.com/entireio/cli/cmd/entire/cli/api"
Expand Down Expand Up @@ -148,3 +149,30 @@ func TestLookupCurrentToken(t *testing.T) {
t.Fatalf("LookupCurrentToken() = %q, want %q", got, "local-token")
}
}

func TestNewStore_UsesTestStoreFile(t *testing.T) {
storeFile := filepath.Join(t.TempDir(), "auth-store.json")
t.Setenv(testAuthStoreFileEnv, storeFile)

store := NewStore()
if err := store.SaveToken("http://localhost:8787", " file-token "); err != nil {
t.Fatalf("SaveToken() error = %v", err)
}

otherProcessStore := NewStore()
got, err := otherProcessStore.GetToken("http://localhost:8787")
if err != nil {
t.Fatalf("GetToken() error = %v", err)
}
if got != "file-token" {
t.Fatalf("GetToken() = %q, want %q", got, "file-token")
}

info, err := os.Stat(storeFile)
if err != nil {
t.Fatalf("stat store file: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("store file mode = %v, want 0600", got)
}
}
2 changes: 2 additions & 0 deletions cmd/entire/cli/integration_test/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -202,6 +203,7 @@ func runLoginProcess(t *testing.T, apiBaseURL string) *loginProcess {
"ENTIRE_TEST_GEMINI_PROJECT_DIR="+env.GeminiProjectDir,
"ENTIRE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,
"ENTIRE_API_BASE_URL="+apiBaseURL,
"ENTIRE_TEST_AUTH_STORE_FILE="+filepath.Join(env.RepoDir, ".entire-test-auth-store.json"),
)

stdoutPipe, err := cmd.StdoutPipe()
Expand Down
5 changes: 3 additions & 2 deletions cmd/entire/cli/integration_test/setup_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
package integration

import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/execx"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/paths"
)
Expand All @@ -21,7 +22,7 @@ func (env *TestEnv) RunEnableWithAccessibleMode() string {

// Run CLI with ACCESSIBLE=1 for non-interactive prompts
// Provide "no" for telemetry
cmd := exec.Command(getTestBinary(), "enable")
cmd := execx.NonInteractive(context.Background(), getTestBinary(), "enable")
cmd.Dir = env.RepoDir
cmd.Env = append(env.cliEnv(), "ACCESSIBLE=1")
// Provide input for telemetry prompt
Expand Down
3 changes: 3 additions & 0 deletions cmd/entire/cli/review/multipicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func PickAgents(ctx context.Context, eligible []AgentChoice) (PickedAgents, erro
if len(eligible) < 2 {
return PickedAgents{}, fmt.Errorf("PickAgents requires at least 2 eligible agents, got %d", len(eligible))
}
if ctx.Err() != nil {
return PickedAgents{}, ErrPickerCancelled
}

// Sort alphabetically for stable display order regardless of how the
// caller populated the slice.
Expand Down
62 changes: 34 additions & 28 deletions cmd/entire/cli/review/tui_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,36 @@ import (
"testing"
"time"

tea "charm.land/bubbletea/v2"

reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
)

func finishAndDismissTUI(t *testing.T, sink *TUISink, summary reviewtypes.RunSummary) {
t.Helper()

done := make(chan struct{})
go func() {
sink.RunFinished(summary)
close(done)
}()

ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()

timeout := time.After(10 * time.Second)
for {
select {
case <-done:
return
case <-ticker.C:
sink.program.Send(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}))
case <-timeout:
t.Fatal("RunFinished() did not return within 10 seconds")
}
}
}

// TestTUISink_StartIsIdempotent verifies that calling Start multiple times
// does not panic or spawn extra goroutines.
func TestTUISink_StartIsIdempotent(t *testing.T) {
Expand All @@ -20,7 +47,7 @@ func TestTUISink_StartIsIdempotent(t *testing.T) {
sink.Start()

// Clean up: send RunFinished so the program exits, then Wait.
sink.RunFinished(reviewtypes.RunSummary{})
finishAndDismissTUI(t, sink, reviewtypes.RunSummary{})

// Wait with a timeout to avoid hanging the test suite on failure.
done := make(chan struct{})
Expand Down Expand Up @@ -93,22 +120,11 @@ func TestTUISink_RunFinished_EventuallyUnblocks(t *testing.T) {
sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: "reviewing…"})
sink.AgentEvent("agent-a", reviewtypes.Finished{Success: true})

done := make(chan struct{})
go func() {
sink.RunFinished(reviewtypes.RunSummary{
AgentRuns: []reviewtypes.AgentRun{
{Name: "agent-a", Status: reviewtypes.AgentStatusSucceeded},
},
})
close(done)
}()

select {
case <-done:
// OK — RunFinished returned (program exited).
case <-time.After(10 * time.Second):
t.Fatal("RunFinished() did not return within 10 seconds")
}
finishAndDismissTUI(t, sink, reviewtypes.RunSummary{
AgentRuns: []reviewtypes.AgentRun{
{Name: "agent-a", Status: reviewtypes.AgentStatusSucceeded},
},
})
Comment thread
khaong marked this conversation as resolved.
}

// TestTUISink_RunFinished_AfterSecondCall_IsNoOp verifies that calling
Expand All @@ -120,17 +136,7 @@ func TestTUISink_RunFinished_AfterSecondCall_IsNoOp(t *testing.T) {
sink.Start()

// First RunFinished should unblock the program.
done := make(chan struct{})
go func() {
sink.RunFinished(reviewtypes.RunSummary{})
close(done)
}()

select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("first RunFinished did not return in time")
}
finishAndDismissTUI(t, sink, reviewtypes.RunSummary{})

// Second call should return immediately (no-op after finished=true).
secondDone := make(chan struct{})
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/setup_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ func resolveRepoName(ctx context.Context, w, errW io.Writer, runner bootstrapRun
Description(fmt.Sprintf("Press enter to use %q", name)).
Value(&input),
),
)
).WithOutput(w)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return "", errBootstrapInterrupted
Expand Down
4 changes: 2 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ run = "gofmt -s -w ."

[tasks.test]
description = "Run tests"
run = "go test ./..."
run = "gotestsum --format pkgname --format-icons text --format-hide-empty-pkg --hide-summary skipped -- ./..."

[tasks."test:integration"]
description = "Run integration tests"
run = "go test -tags=integration ./cmd/entire/cli/integration_test/..."
run = "gotestsum --format testname --format-icons text --hide-summary skipped -- -tags=integration ./cmd/entire/cli/integration_test/..."

[tasks."test:ci"]
description = "Run all tests (unit + integration + E2E canary) with race detection"
Expand Down
Loading