diff --git a/internal/acp/acp_extra_test.go b/internal/acp/acp_extra_test.go new file mode 100644 index 00000000..6e68264f --- /dev/null +++ b/internal/acp/acp_extra_test.go @@ -0,0 +1,438 @@ +package acp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +// --- Error factory for testing --- + +func errorFactory() (*engine.Session, error) { + return nil, errors.New("factory error") +} + +// --- handleSessionNew tests --- + +func TestHandleSessionNew_FactoryError(t *testing.T) { + srv := NewServer(errorFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/new", + } + srv.handleSessionNew(msg) + + // Should write an error response + if buf.Len() == 0 { + t.Fatal("expected error response to be written") + } + var resp rpcMessage + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Error == nil { + t.Fatal("expected error in response") + } + if resp.Error.Code != errCodeInternal { + t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInternal) + } +} + +// --- handleCancel tests --- + +func TestHandleCancel_ValidSession(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + // Create a session first + sess, _ := testFactory() + srv.mu.Lock() + srv.sessions["test-sess"] = &acpSession{sess: sess} + srv.mu.Unlock() + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/cancel", + Params: []byte(`{"sessionId":"test-sess"}`), + } + srv.handleCancel(msg) + // Cancel is a notification, no response expected +} + +func TestHandleCancel_UnknownSession(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/cancel", + Params: []byte(`{"sessionId":"unknown"}`), + } + srv.handleCancel(msg) + // Should not crash, no response for unknown session +} + +func TestHandleCancel_InvalidParams(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/cancel", + Params: []byte(`{invalid json`), + } + srv.handleCancel(msg) + // Should not crash on invalid params +} + +// --- handlePrompt tests --- + +func TestHandlePrompt_InvalidParams(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/prompt", + Params: []byte(`{invalid json`), + } + srv.handlePrompt(context.Background(), msg) + + if buf.Len() == 0 { + t.Fatal("expected error response to be written") + } + var resp rpcMessage + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Error == nil { + t.Fatal("expected error in response") + } + if resp.Error.Code != errCodeInvalidParams { + t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInvalidParams) + } +} + +func TestHandlePrompt_UnknownSession(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + ID: []byte(`1`), + Method: "session/prompt", + Params: []byte(`{"sessionId":"unknown","prompt":[{"type":"text","text":"hi"}]}`), + } + srv.handlePrompt(context.Background(), msg) + + if buf.Len() == 0 { + t.Fatal("expected error response to be written") + } + var resp rpcMessage + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Error == nil { + t.Fatal("expected error in response") + } +} + +// --- permissionFnFor tests --- + +// Note: We can't easily test permissionFnFor timeout because it blocks for 5 minutes +// waiting for a client response. This is by design - it's a blocking RPC call. +// The existing integration tests cover the happy path. + +// --- routeResponse tests --- + +func TestRouteResponse_UnknownID(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + // Route a response for an unknown request ID + msg := rpcMessage{ + ID: []byte(`999`), + Result: []byte(`{"result":"ok"}`), + } + srv.routeResponse(msg) + // Should not crash +} + +func TestRouteResponse_InvalidID(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + // Route a response with invalid ID + msg := rpcMessage{ + ID: []byte(`not-a-number`), + Result: []byte(`{"result":"ok"}`), + } + srv.routeResponse(msg) + // Should not crash +} + +// --- writeMessage tests --- + +func TestWriteMessage_ValidMessage(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + msg := rpcMessage{ + JSONRPC: "2.0", + ID: []byte(`1`), + Result: []byte(`{"test":"data"}`), + } + srv.writeMessage(msg) + + if buf.Len() == 0 { + t.Fatal("expected message to be written") + } + // Should end with newline + if !strings.HasSuffix(buf.String(), "\n") { + t.Error("expected message to end with newline") + } +} + +// --- mustRaw tests --- + +func TestMustRaw_ValidValue(t *testing.T) { + result := mustRaw(map[string]any{"key": "value"}) + if len(result) == 0 { + t.Error("expected non-empty result") + } + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + if parsed["key"] != "value" { + t.Errorf("expected key=value, got %v", parsed["key"]) + } +} + +func TestMustRaw_NilValue(t *testing.T) { + result := mustRaw(nil) + // Should return "null" for nil + if string(result) != "null" { + t.Errorf("expected 'null', got %q", string(result)) + } +} + +// --- reply tests --- + +func TestReply_WithValidID(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + srv.reply([]byte(`1`), map[string]any{"result": "ok"}) + if buf.Len() == 0 { + t.Fatal("expected reply to be written") + } +} + +func TestReply_WithEmptyID(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + // Empty ID means notification, no reply expected + srv.reply([]byte(``), map[string]any{"result": "ok"}) + if buf.Len() != 0 { + t.Error("expected no reply for empty ID") + } +} + +// --- writeError tests --- + +func TestWriteError_ValidError(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + srv.writeError([]byte(`1`), errCodeMethodNotFound, "method not found") + if buf.Len() == 0 { + t.Fatal("expected error to be written") + } + var resp rpcMessage + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Error == nil { + t.Fatal("expected error in response") + } + if resp.Error.Code != errCodeMethodNotFound { + t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeMethodNotFound) + } +} + +// --- sessionUpdate tests --- + +func TestSessionUpdate_ValidUpdate(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + srv.sessionUpdate("test-session", map[string]any{ + "sessionUpdate": "agent_message_chunk", + "content": map[string]any{"type": "text", "text": "hello"}, + }) + if buf.Len() == 0 { + t.Fatal("expected session update to be written") + } + var msg rpcMessage + if err := json.Unmarshal(buf.Bytes(), &msg); err != nil { + t.Fatalf("failed to parse message: %v", err) + } + if msg.Method != "session/update" { + t.Errorf("method = %q, want %q", msg.Method, "session/update") + } +} + +// --- Serve tests --- + +func TestServe_CancelledContext(t *testing.T) { + srv := NewServer(testFactory) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Serve with cancelled context should return context error + err := srv.Serve(ctx, strings.NewReader(""), io.Discard) + if err == nil { + t.Error("expected error for cancelled context") + } +} + +func TestServe_EmptyInput(t *testing.T) { + srv := NewServer(testFactory) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + // Empty input should return nil (clean EOF) + err := srv.Serve(ctx, strings.NewReader(""), io.Discard) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServe_InvalidJSON(t *testing.T) { + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + // Invalid JSON should write parse error + err := srv.Serve(ctx, strings.NewReader("{invalid\n"), &buf) + // May or may not error depending on timing, but should write parse error + if buf.Len() == 0 && err == nil { + t.Error("expected parse error to be written") + } +} + +func TestACP_FullSessionLifecycle(t *testing.T) { + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + `{"jsonrpc":"2.0","id":2,"method":"session/new"}`, + `{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"hello"}]}}`, + `{"jsonrpc":"2.0","id":4,"method":"session/cancel","params":{"sessionId":"sess_1"}}`, + } + msgs := runServer(t, testFactory, lines) + + // Should have responses for all requests + ids := make(map[int]bool) + for _, m := range msgs { + if len(m.ID) > 0 && m.Method == "" { + var id int + if err := json.Unmarshal(m.ID, &id); err == nil { + ids[id] = true + } + } + } + for _, expected := range []int{1, 2, 3} { + if !ids[expected] { + t.Errorf("missing response for id %d", expected) + } + } +} + +func TestACP_MultipleSessions(t *testing.T) { + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + `{"jsonrpc":"2.0","id":2,"method":"session/new"}`, + `{"jsonrpc":"2.0","id":3,"method":"session/new"}`, + `{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"hello"}]}}`, + `{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"sess_2","prompt":[{"type":"text","text":"world"}]}}`, + } + msgs := runServer(t, testFactory, lines) + + sessionCount := 0 + for _, m := range msgs { + if hasID(m, 2) || hasID(m, 3) { + var r struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(m.Result, &r); err == nil && r.SessionID != "" { + sessionCount++ + } + } + } + if sessionCount != 2 { + t.Errorf("expected 2 sessions created, got %d", sessionCount) + } +} + +func TestACP_EmptyPrompt(t *testing.T) { + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + `{"jsonrpc":"2.0","id":2,"method":"session/new"}`, + `{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[]}}`, + } + msgs := runServer(t, testFactory, lines) + + var gotPromptResult bool + for _, m := range msgs { + if hasID(m, 3) { + gotPromptResult = true + } + } + if !gotPromptResult { + t.Error("expected prompt result even with empty prompt") + } +} + +func TestACP_MixedPromptTypes(t *testing.T) { + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + `{"jsonrpc":"2.0","id":2,"method":"session/new"}`, + `{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"hello"},{"type":"image","data":"base64data"}]}}`, + } + msgs := runServer(t, testFactory, lines) + + var gotPromptResult bool + for _, m := range msgs { + if hasID(m, 3) { + gotPromptResult = true + } + } + if !gotPromptResult { + t.Error("expected prompt result with mixed prompt types") + } +} diff --git a/internal/auth/storage_extra_test.go b/internal/auth/storage_extra_test.go new file mode 100644 index 00000000..b8b05d80 --- /dev/null +++ b/internal/auth/storage_extra_test.go @@ -0,0 +1,172 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSecureStorage_GetSet_Fallback tests the file-based fallback storage +// used on Linux when no OS keyring is available. +func TestSecureStorage_GetSet_Fallback(t *testing.T) { + // Override config dir to use a temp directory + tmpDir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", tmpDir) + + ss := &SecureStorage{service: "test-service"} + + // Set a token + err := ss.Set("test-account", "secret-token") + if err != nil { + t.Fatalf("Set failed: %v", err) + } + + // Get the token back + token, err := ss.Get("test-account") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if token != "secret-token" { + t.Errorf("expected 'secret-token', got %q", token) + } +} + +// TestSecureStorage_GetSet_MultipleAccounts tests storing multiple accounts. +func TestSecureStorage_GetSet_MultipleAccounts(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", tmpDir) + + ss := &SecureStorage{service: "test-service"} + + // Set multiple tokens + if err := ss.Set("account1", "token1"); err != nil { + t.Fatalf("Set account1 failed: %v", err) + } + if err := ss.Set("account2", "token2"); err != nil { + t.Fatalf("Set account2 failed: %v", err) + } + + // Retrieve both + t1, err := ss.Get("account1") + if err != nil { + t.Fatalf("Get account1 failed: %v", err) + } + if t1 != "token1" { + t.Errorf("expected 'token1', got %q", t1) + } + + t2, err := ss.Get("account2") + if err != nil { + t.Fatalf("Get account2 failed: %v", err) + } + if t2 != "token2" { + t.Errorf("expected 'token2', got %q", t2) + } +} + +// TestSecureStorage_Get_NonExistent tests getting a token that doesn't exist. +func TestSecureStorage_Get_NonExistent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", tmpDir) + + ss := &SecureStorage{service: "test-service"} + + // Get should fail because no token file exists + _, err := ss.Get("non-existent-account") + if err == nil { + t.Error("expected error for non-existent account") + } +} + +// TestSecureStorage_Set_OverwritesExisting tests that Set overwrites existing tokens. +func TestSecureStorage_Set_OverwritesExisting(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", tmpDir) + + ss := &SecureStorage{service: "test-service"} + + // Set initial token + if err := ss.Set("account", "old-token"); err != nil { + t.Fatalf("Set old-token failed: %v", err) + } + + // Overwrite with new token + if err := ss.Set("account", "new-token"); err != nil { + t.Fatalf("Set new-token failed: %v", err) + } + + // Verify new token + token, err := ss.Get("account") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if token != "new-token" { + t.Errorf("expected 'new-token', got %q", token) + } +} + +// TestSecureStorage_GetFile_CorruptJSON tests that corrupt JSON returns an error. +func TestSecureStorage_GetFile_CorruptJSON(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HAWK_CONFIG_DIR", tmpDir) + + // Write corrupt JSON to the token file + tokenFile := filepath.Join(tmpDir, ".tokens") + if err := os.WriteFile(tokenFile, []byte("not valid json{"), 0o600); err != nil { + t.Fatal(err) + } + + ss := &SecureStorage{service: "test-service"} + _, err := ss.Get("any-account") + if err == nil { + t.Error("expected error for corrupt JSON") + } +} + +// TestSecurityQuote tests the securityQuote helper function. +func TestSecurityQuote_Extra(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", `"simple"`}, + {"with\"quote", `"with\"quote"`}, + {"with\\backslash", `"with\\backslash"`}, + {"", `""`}, + } + + for _, tc := range tests { + result := securityQuote(tc.input) + if result != tc.expected { + t.Errorf("securityQuote(%q) = %q, want %q", tc.input, result, tc.expected) + } + } +} + +// TestPowershellQuote tests the powershellQuote helper function. +func TestPowershellQuote_Extra(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"with'quote", "with''quote"}, + {"multiple'quotes'here", "multiple''quotes''here"}, + {"", ""}, + } + + for _, tc := range tests { + result := powershellQuote(tc.input) + if result != tc.expected { + t.Errorf("powershellQuote(%q) = %q, want %q", tc.input, result, tc.expected) + } + } +} + +// TestNewSecureStorage_Extra tests the constructor. +func TestNewSecureStorage_Extra(t *testing.T) { + ss := NewSecureStorage("my-service") + if ss.service != "my-service" { + t.Errorf("expected service 'my-service', got %q", ss.service) + } +} diff --git a/internal/crash/crash_extra_test.go b/internal/crash/crash_extra_test.go new file mode 100644 index 00000000..d2b0cdc9 --- /dev/null +++ b/internal/crash/crash_extra_test.go @@ -0,0 +1,352 @@ +package crash + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const testEnvStateDir = "HAWK_STATE_DIR" + +// --- WriteReport tests --- + +func TestWriteReport_ValidInput(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + stack := []byte("goroutine 1 [running]:\nmain.main()") + path, err := WriteReport("test panic", stack) + if err != nil { + t.Fatalf("WriteReport returned error: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } + + // Verify file exists + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + if !strings.Contains(string(content), "test panic") { + t.Error("report should contain panic value") + } + if !strings.Contains(string(content), "goroutine") { + t.Error("report should contain stack trace") + } +} + +func TestWriteReport_NilRecovered(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + path, err := WriteReport(nil, []byte("stack trace")) + if err != nil { + t.Fatalf("WriteReport returned error: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + // Should not contain "panic value" line since recovered is nil + if strings.Contains(string(content), "panic value") { + t.Error("report should not contain panic value for nil recovered") + } +} + +func TestWriteReport_EmptyStack(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + path, err := WriteReport("error", []byte{}) + if err != nil { + t.Fatalf("WriteReport returned error: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } +} + +func TestWriteReport_CreatesDirectory(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + // Directory shouldn't exist yet + reportDirPath := filepath.Join(stateDir, reportDirName) + if _, err := os.Stat(reportDirPath); !os.IsNotExist(err) { + t.Fatal("report dir should not exist before WriteReport") + } + + // WriteReport should create it + _, err := WriteReport("test", []byte("stack")) + if err != nil { + t.Fatalf("WriteReport returned error: %v", err) + } + + // Directory should now exist + info, err := os.Stat(reportDirPath) + if err != nil { + t.Fatalf("report dir should exist: %v", err) + } + if !info.IsDir() { + t.Error("report path should be a directory") + } +} + +// --- formatReport tests --- + +func TestFormatReport_FullFields(t *testing.T) { + r := CrashReport{ + Timestamp: mustParseTime(t, "2024-01-15T10:30:00Z"), + Version: "1.2.3", + PanicValue: "runtime error: nil pointer", + Signal: "SIGSEGV", + Stack: "goroutine 1 [running]:\nmain.main()", + } + + result := formatReport(r) + if !strings.Contains(result, "hawk crash report") { + t.Error("report should have header") + } + if !strings.Contains(result, "1.2.3") { + t.Error("report should contain version") + } + if !strings.Contains(result, "nil pointer") { + t.Error("report should contain panic value") + } + if !strings.Contains(result, "SIGSEGV") { + t.Error("report should contain signal") + } + if !strings.Contains(result, "goroutine") { + t.Error("report should contain stack") + } +} + +func TestFormatReport_MinimalFields(t *testing.T) { + r := CrashReport{ + Timestamp: mustParseTime(t, "2024-01-15T10:30:00Z"), + Stack: "stack trace only", + } + + result := formatReport(r) + if strings.Contains(result, "version:") { + t.Error("report should not contain version when empty") + } + if strings.Contains(result, "panic value:") { + t.Error("report should not contain panic value when empty") + } + if strings.Contains(result, "signal:") { + t.Error("report should not contain signal when empty") + } +} + +func TestFormatReport_TimestampFormat(t *testing.T) { + r := CrashReport{ + Timestamp: mustParseTime(t, "2024-01-15T10:30:00.123456789Z"), + Stack: "stack", + } + + result := formatReport(r) + // Should use RFC3339Nano format + if !strings.Contains(result, "2024-01-15T10:30:00") { + t.Errorf("report should contain formatted timestamp, got: %s", result) + } +} + +// --- pruneReports tests --- + +func TestPruneReports_UnderLimit(t *testing.T) { + dir := t.TempDir() + + // Create fewer files than the limit with different names + for i := 0; i < 5; i++ { + name := filepath.Join(dir, fmt.Sprintf("crash-2024010%dT000000.000Z.txt", i)) + if err := os.WriteFile(name, []byte("report"), 0o600); err != nil { + t.Fatal(err) + } + } + + pruneReports(dir) + + // All files should still exist + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 5 { + t.Errorf("expected 5 files, got %d", len(entries)) + } +} + +func TestPruneReports_OverLimit(t *testing.T) { + dir := t.TempDir() + + // Create more files than the limit with different names + for i := 0; i < 15; i++ { + name := filepath.Join(dir, fmt.Sprintf("crash-2024010%dT000000.000Z.txt", i)) + if err := os.WriteFile(name, []byte("report"), 0o600); err != nil { + t.Fatal(err) + } + } + + pruneReports(dir) + + // Should have maxReports files remaining + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != maxReports { + t.Errorf("expected %d files, got %d", maxReports, len(entries)) + } +} + +func TestPruneReports_IgnoresNonTxtFiles(t *testing.T) { + dir := t.TempDir() + + // Create some .txt files and some non-.txt files + for i := 0; i < 5; i++ { + name := filepath.Join(dir, "crash-20240101T000000.000Z.txt") + if err := os.WriteFile(name, []byte("report"), 0o600); err != nil { + t.Fatal(err) + } + } + // Create a non-.txt file that should be ignored + otherFile := filepath.Join(dir, "other.json") + if err := os.WriteFile(otherFile, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + + pruneReports(dir) + + // Non-.txt file should still exist + if _, err := os.Stat(otherFile); err != nil { + t.Error("non-.txt file should not be pruned") + } +} + +func TestPruneReports_IgnoresDirectories(t *testing.T) { + dir := t.TempDir() + + // Create a subdirectory + subDir := filepath.Join(dir, "subdir") + if err := os.MkdirAll(subDir, 0o700); err != nil { + t.Fatal(err) + } + + pruneReports(dir) + + // Subdirectory should still exist + if _, err := os.Stat(subDir); err != nil { + t.Error("subdirectory should not be pruned") + } +} + +// --- CaptureGoroutines tests --- + +func TestCaptureGoroutines_ReturnsData(t *testing.T) { + result := CaptureGoroutines() + if len(result) == 0 { + t.Error("CaptureGoroutines should return non-empty result") + } + // Should contain goroutine information + if !strings.Contains(string(result), "goroutine") { + t.Error("result should contain 'goroutine'") + } +} + +func TestCaptureGoroutines_NeverPanics(t *testing.T) { + // Just verify it doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("CaptureGoroutines panicked: %v", r) + } + }() + _ = CaptureGoroutines() +} + +// --- reportDir tests --- + +func TestReportDir_CreatesDirectory(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + dir, err := reportDir() + if err != nil { + t.Fatalf("reportDir returned error: %v", err) + } + + // Should be stateDir/crash + expected := filepath.Join(stateDir, reportDirName) + if dir != expected { + t.Errorf("reportDir = %q, want %q", dir, expected) + } + + // Directory should exist + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("directory should exist: %v", err) + } + if !info.IsDir() { + t.Error("should be a directory") + } +} + +func TestReportDir_Idempotent(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(testEnvStateDir, stateDir) + + // Call twice - should succeed both times + dir1, err := reportDir() + if err != nil { + t.Fatalf("first call returned error: %v", err) + } + dir2, err := reportDir() + if err != nil { + t.Fatalf("second call returned error: %v", err) + } + if dir1 != dir2 { + t.Errorf("reportDir should be idempotent: %q vs %q", dir1, dir2) + } +} + +// --- CrashReport struct tests --- + +func TestCrashReport_StructCreation(t *testing.T) { + r := CrashReport{ + Timestamp: mustParseTime(t, "2024-01-15T10:30:00Z"), + PanicValue: "test", + Stack: "stack", + } + if r.Timestamp.IsZero() { + t.Error("Timestamp should be set") + } + if r.PanicValue != "test" { + t.Error("PanicValue should be set") + } + if r.Stack != "stack" { + t.Error("Stack should be set") + } +} + +// --- Helper --- + +func mustParseTime(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + // Try without nanoseconds + ts, err = time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("failed to parse time %q: %v", s, err) + } + } + return ts +} diff --git a/internal/lint/lint_extra_test.go b/internal/lint/lint_extra_test.go new file mode 100644 index 00000000..2d40be76 --- /dev/null +++ b/internal/lint/lint_extra_test.go @@ -0,0 +1,350 @@ +package lint + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestEslintLinter_NoTool tests eslint linter when npx/eslint are not available. +func TestEslintLinter_NoTool(t *testing.T) { + // Use a restricted PATH that doesn't have npx or eslint + origPath := os.Getenv("PATH") + t.Setenv("PATH", t.TempDir()) + defer os.Setenv("PATH", origPath) + + linter := eslintLinter{} + if linter.Name() != "eslint" { + t.Errorf("expected name 'eslint', got %q", linter.Name()) + } + + dir := t.TempDir() + file := filepath.Join(dir, "test.js") + if err := os.WriteFile(file, []byte("const x = 1;"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + // Without npx/eslint, should return OK=true (no-op) + if !res.OK { + t.Errorf("expected OK=true when eslint not available, got %+v", res) + } + if res.Ran { + t.Error("expected Ran=false when eslint not available") + } +} + +// TestRuffLinter_NoTool tests ruff linter when ruff is not available. +func TestRuffLinter_NoTool(t *testing.T) { + origPath := os.Getenv("PATH") + t.Setenv("PATH", t.TempDir()) + defer os.Setenv("PATH", origPath) + + linter := ruffLinter{} + if linter.Name() != "ruff" { + t.Errorf("expected name 'ruff', got %q", linter.Name()) + } + + dir := t.TempDir() + file := filepath.Join(dir, "test.py") + if err := os.WriteFile(file, []byte("x = 1\n"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + // Without ruff, should return OK=true (no-op) + if !res.OK { + t.Errorf("expected OK=true when ruff not available, got %+v", res) + } + if res.Ran { + t.Error("expected Ran=false when ruff not available") + } +} + +// TestGoLinter_NoTool tests go linter when go/gofmt are not available. +func TestGoLinter_NoTool(t *testing.T) { + origPath := os.Getenv("PATH") + t.Setenv("PATH", t.TempDir()) + defer os.Setenv("PATH", origPath) + + linter := goLinter{} + if linter.Name() != "go vet/gofmt" { + t.Errorf("expected name 'go vet/gofmt', got %q", linter.Name()) + } + + dir := t.TempDir() + file := filepath.Join(dir, "test.go") + if err := os.WriteFile(file, []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + // Without go/gofmt, should return OK=true (no-op) + if !res.OK { + t.Errorf("expected OK=true when go not available, got %+v", res) + } +} + +// TestCustomLinter_Success tests a custom linter that succeeds. +func TestCustomLinter_Success(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "x.go") + if err := os.WriteFile(file, []byte("package x\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := Config{Enabled: true, Custom: map[string]string{"go": "echo all good {file}"}} + res := RunLint(context.Background(), file, cfg) + if !res.Ran || !res.OK { + t.Fatalf("expected custom linter success, got %+v", res) + } + if res.Output != "all good "+file { + t.Errorf("expected output 'all good ', got %q", res.Output) + } +} + +// TestCustomLinter_EmptyOutput tests a custom linter that fails with no output. +func TestCustomLinter_EmptyOutput(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "x.go") + if err := os.WriteFile(file, []byte("package x\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := Config{Enabled: true, Custom: map[string]string{"go": "exit 1"}} + res := RunLint(context.Background(), file, cfg) + if !res.Ran || res.OK { + t.Fatalf("expected custom linter failure, got %+v", res) + } + // Output should contain the error message since stdout was empty + if res.Output == "" { + t.Error("expected non-empty output (error message)") + } +} + +// TestRunLint_WithTimeout tests RunLint with a custom timeout. +func TestRunLint_WithTimeout(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "x.go") + if err := os.WriteFile(file, []byte("package x\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := Config{ + Enabled: true, + Custom: map[string]string{"go": "echo done"}, + Timeout: 5000000000, // 5s + } + res := RunLint(context.Background(), file, cfg) + if !res.OK { + t.Errorf("expected OK, got %+v", res) + } +} + +// TestLanguageForFile tests LanguageForFile. +func TestLanguageForFile(t *testing.T) { + tests := []struct { + file string + want string + }{ + {"main.go", "go"}, + {"app.js", "js"}, + {"app.tsx", "ts"}, + {"script.py", "python"}, + {"readme.md", ""}, + {"noext", ""}, + } + for _, tt := range tests { + if got := LanguageForFile(tt.file); got != tt.want { + t.Errorf("LanguageForFile(%q) = %q, want %q", tt.file, got, tt.want) + } + } +} + +// TestRunLint_LanguageSet tests that RunLint sets the Language field. +func TestRunLint_LanguageSet(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "x.py") + if err := os.WriteFile(file, []byte("x = 1\n"), 0644); err != nil { + t.Fatal(err) + } + + // With ruff not available, should still set Language + origPath := os.Getenv("PATH") + t.Setenv("PATH", t.TempDir()) + defer os.Setenv("PATH", origPath) + + cfg := Config{Enabled: true} + res := RunLint(context.Background(), file, cfg) + if res.Language != "python" { + t.Errorf("expected language 'python', got %q", res.Language) + } +} + +// TestCustomLinter_Name tests the custom linter Name method. +func TestCustomLinter_Name(t *testing.T) { + linter := &customLinter{lang: "ruby", command: "rubocop"} + if linter.Name() != "custom:ruby" { + t.Errorf("expected 'custom:ruby', got %q", linter.Name()) + } +} + +// TestEslintLinter_WithMockNpx tests eslint linter with a mock npx that succeeds. +func TestEslintLinter_WithMockNpx(t *testing.T) { + // Create a mock npx that exits 0 with no output + mockDir := t.TempDir() + mockNpx := filepath.Join(mockDir, "npx") + if err := os.WriteFile(mockNpx, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := eslintLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.js") + if err := os.WriteFile(file, []byte("const x = 1;"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + if !res.OK || !res.Ran { + t.Errorf("expected OK=true Ran=true with mock npx, got %+v", res) + } +} + +// TestEslintLinter_WithMockNpx_Failure tests eslint linter with a mock npx that fails. +func TestEslintLinter_WithMockNpx_Failure(t *testing.T) { + mockDir := t.TempDir() + mockNpx := filepath.Join(mockDir, "npx") + if err := os.WriteFile(mockNpx, []byte("#!/bin/sh\necho 'error: semicolon missing'\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := eslintLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.js") + if err := os.WriteFile(file, []byte("const x = 1"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + if res.OK { + t.Error("expected OK=false with failing mock npx") + } + if !res.Ran { + t.Error("expected Ran=true") + } +} + +// TestEslintLinter_WithMockNpx_EmptyOutput tests eslint with npx that fails silently. +func TestEslintLinter_WithMockNpx_EmptyOutput(t *testing.T) { + mockDir := t.TempDir() + mockNpx := filepath.Join(mockDir, "npx") + // npx --no-install miss: exits non-zero with no output + if err := os.WriteFile(mockNpx, []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := eslintLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.js") + if err := os.WriteFile(file, []byte("const x = 1;"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + // Empty output + error = npx miss, treated as no-op + if !res.OK { + t.Errorf("expected OK=true for npx miss, got %+v", res) + } +} + +// TestEslintLinter_WithMockEslint tests eslint linter with a direct eslint binary. +func TestEslintLinter_WithMockEslint(t *testing.T) { + mockDir := t.TempDir() + mockEslint := filepath.Join(mockDir, "eslint") + if err := os.WriteFile(mockEslint, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := eslintLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.js") + if err := os.WriteFile(file, []byte("const x = 1;"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + if !res.OK || !res.Ran { + t.Errorf("expected OK=true Ran=true with mock eslint, got %+v", res) + } +} + +// TestRuffLinter_WithMockRuff tests ruff linter with a mock ruff that succeeds. +func TestRuffLinter_WithMockRuff(t *testing.T) { + mockDir := t.TempDir() + mockRuff := filepath.Join(mockDir, "ruff") + if err := os.WriteFile(mockRuff, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := ruffLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.py") + if err := os.WriteFile(file, []byte("x = 1\n"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + if !res.OK || !res.Ran { + t.Errorf("expected OK=true Ran=true with mock ruff, got %+v", res) + } +} + +// TestRuffLinter_WithMockRuff_Failure tests ruff linter with a mock ruff that fails. +func TestRuffLinter_WithMockRuff_Failure(t *testing.T) { + mockDir := t.TempDir() + mockRuff := filepath.Join(mockDir, "ruff") + if err := os.WriteFile(mockRuff, []byte("#!/bin/sh\necho 'E501 line too long'\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", mockDir) + defer os.Setenv("PATH", origPath) + + linter := ruffLinter{} + dir := t.TempDir() + file := filepath.Join(dir, "test.py") + if err := os.WriteFile(file, []byte("x = 1\n"), 0644); err != nil { + t.Fatal(err) + } + + res := linter.Lint(context.Background(), file) + if res.OK { + t.Error("expected OK=false with failing mock ruff") + } + if !res.Ran { + t.Error("expected Ran=true") + } +} diff --git a/internal/lsp/lsp_client_test.go b/internal/lsp/lsp_client_test.go new file mode 100644 index 00000000..41a6e3fe --- /dev/null +++ b/internal/lsp/lsp_client_test.go @@ -0,0 +1,237 @@ +package lsp + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestNewLSPClient_InvalidCommand_Extra tests that an invalid command returns an error. +func TestNewLSPClient_InvalidCommand_Extra(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cfg := ServerConfig{ + Command: "nonexistent-lsp-command-xyz123", + Extensions: []string{".go"}, + } + + _, err := NewLSPClient(ctx, "go", cfg) + if err == nil { + t.Error("expected error for invalid command") + } +} + +// TestNewLSPClient_ContextCanceled_Extra tests that a canceled context causes an error. +func TestNewLSPClient_ContextCanceled_Extra(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + cfg := ServerConfig{ + Command: "gopls", + Extensions: []string{".go"}, + } + + _, err := NewLSPClient(ctx, "go", cfg) + if err == nil { + t.Error("expected error for canceled context") + } +} + +// TestLSPConfig_LanguageForExtension_Extra tests the LanguageForExtension method. +func TestLSPConfig_LanguageForExtension_Extra(t *testing.T) { + ResetConfig() + cfg := LoadConfig("") + + // Test with extension prefix + lang := cfg.LanguageForExtension(".go") + if lang != "go" { + t.Errorf("expected 'go', got %q", lang) + } + + // Test without extension prefix + lang = cfg.LanguageForExtension("py") + if lang != "python" { + t.Errorf("expected 'python', got %q", lang) + } + + // Test unknown extension + lang = cfg.LanguageForExtension(".xyz") + if lang != "" { + t.Errorf("expected empty string, got %q", lang) + } +} + +// TestLSPConfig_ServerForFile_Extra tests the ServerForFile method. +func TestLSPConfig_ServerForFile_Extra(t *testing.T) { + ResetConfig() + cfg := LoadConfig("") + + // Test Go file + lang, server, ok := cfg.ServerForFile("test.go") + if !ok { + t.Error("expected ok=true for .go file") + } + if lang != "go" { + t.Errorf("expected lang='go', got %q", lang) + } + if server.Command != "gopls" { + t.Errorf("expected command='gopls', got %q", server.Command) + } + + // Test TypeScript file + lang, server, ok = cfg.ServerForFile("test.ts") + if !ok { + t.Error("expected ok=true for .ts file") + } + if lang != "typescript" { + t.Errorf("expected lang='typescript', got %q", lang) + } + + // Test unknown file + lang, server, ok = cfg.ServerForFile("test.xyz") + if ok { + t.Error("expected ok=false for .xyz file") + } + if lang != "" { + t.Errorf("expected empty lang, got %q", lang) + } +} + +// TestLoadConfig_Extra tests loading config from a file. +func TestLoadConfig_Extra(t *testing.T) { + ResetConfig() + + // Create a temp directory with a custom config + dir := t.TempDir() + agentsDir := filepath.Join(dir, ".agents") + if err := os.MkdirAll(agentsDir, 0755); err != nil { + t.Fatalf("failed to create .agents dir: %v", err) + } + + // Write a custom config + customConfig := `{"lsp": {"custom-lang": {"command": "custom-lsp", "extensions": [".custom"]}}}` + configPath := filepath.Join(agentsDir, "lsp.json") + if err := os.WriteFile(configPath, []byte(customConfig), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg := LoadConfig(dir) + + // Check that custom config was loaded + server, ok := cfg.Servers["custom-lang"] + if !ok { + t.Error("expected custom-lang server to be configured") + } + if server.Command != "custom-lsp" { + t.Errorf("expected command='custom-lsp', got %q", server.Command) + } + + // Check that defaults are still present + server, ok = cfg.Servers["go"] + if !ok { + t.Error("expected go server to still be configured") + } +} + +// TestLoadConfig_InvalidJSON_Extra tests loading config with invalid JSON. +func TestLoadConfig_InvalidJSON_Extra(t *testing.T) { + ResetConfig() + + dir := t.TempDir() + agentsDir := filepath.Join(dir, ".agents") + if err := os.MkdirAll(agentsDir, 0755); err != nil { + t.Fatalf("failed to create .agents dir: %v", err) + } + + // Write invalid JSON + configPath := filepath.Join(agentsDir, "lsp.json") + if err := os.WriteFile(configPath, []byte("{invalid json}"), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + // Should not panic, should load defaults + cfg := LoadConfig(dir) + if cfg == nil { + t.Fatal("expected non-nil config") + } + + // Defaults should still be present + if _, ok := cfg.Servers["go"]; !ok { + t.Error("expected go server to be configured from defaults") + } +} + +// TestLSPClient_Call_Closed_Extra tests calling a method on a closed client. +// We create a client with a valid command (gopls) but it will fail to initialize, +// so we test the closed state by creating a client and closing it. +func TestLSPClient_Call_Closed_Extra(t *testing.T) { + // Use a simple echo command that will start but not respond properly to LSP + // The client will fail to initialize, but we can still test the closed state + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // This will fail to initialize, but we can still test the closed state + // by creating a client manually + client := &LSPClient{ + cmd: exec.Command("echo", "test"), + pending: make(map[interface{}]chan json.RawMessage), + language: "go", + } + client.closed.Store(true) + + // Call should return error for closed client + _, err := client.call(ctx, "test", nil) + if err == nil { + t.Error("expected error for closed client") + } +} + +// TestLSPClient_Notify_AfterClose_Extra tests notifying after the client is closed. +func TestLSPClient_Notify_AfterClose_Extra(t *testing.T) { + client := &LSPClient{ + cmd: exec.Command("echo", "test"), + pending: make(map[interface{}]chan json.RawMessage), + language: "go", + } + client.closed.Store(true) + + // Notify should return an error (client is closed) + // Actually, notify doesn't check closed state, so it will try to write + // We can't test this without a real stdin +} + +// TestLSPClient_Language_Extra tests the Language method. +func TestLSPClient_Language_Extra(t *testing.T) { + client := &LSPClient{ + language: "python", + pending: make(map[interface{}]chan json.RawMessage), + } + + if client.Language() != "python" { + t.Errorf("expected language 'python', got %q", client.Language()) + } +} + +// TestLSPClient_Call_ClosedState_Extra tests that a closed client returns an error. +func TestLSPClient_Call_ClosedState_Extra(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client := &LSPClient{ + cmd: exec.Command("echo", "test"), + pending: make(map[interface{}]chan json.RawMessage), + language: "go", + } + client.closed.Store(true) + + // Call should return error for closed client + _, err := client.call(ctx, "textDocument/diagnostic", nil) + if err == nil { + t.Error("expected error for closed client") + } +} diff --git a/internal/sandbox/config_extra_test.go b/internal/sandbox/config_extra_test.go new file mode 100644 index 00000000..cfc7d8e8 --- /dev/null +++ b/internal/sandbox/config_extra_test.go @@ -0,0 +1,300 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// TestResolveMode_Default tests the default mode resolution. +func TestResolveMode_Default(t *testing.T) { + cfg := TOMLConfig{ + Profiles: map[string]ProfileConfig{ + ProfileWorkspace: {Mode: "strict"}, + }, + } + mode := resolveMode(cfg, "") + if mode != ModeStrict { + t.Errorf("expected ModeStrict, got %v", mode) + } +} + +// TestResolveMode_UnknownProfile tests fallback to ParseMode for unknown profiles. +func TestResolveMode_UnknownProfile(t *testing.T) { + cfg := TOMLConfig{} + mode := resolveMode(cfg, "unknown") + if mode != ModeStrict { + t.Errorf("expected ModeStrict for unknown profile, got %v", mode) + } +} + +// TestResolveMode_Aliases tests builtin profile name aliases. +func TestResolveMode_Aliases(t *testing.T) { + cfg := TOMLConfig{} + tests := []struct { + name string + expected Mode + }{ + {"readonly", ModeStrict}, + {"read_only", ModeStrict}, + } + + for _, tc := range tests { + mode := resolveMode(cfg, tc.name) + if mode != tc.expected { + t.Errorf("resolveMode(%q) = %v, want %v", tc.name, mode, tc.expected) + } + } +} + +// TestResolveMode_Extends tests profile extension. +func TestResolveMode_Extends(t *testing.T) { + cfg := TOMLConfig{ + Profiles: map[string]ProfileConfig{ + "base": {Mode: "strict"}, + "child": {Extends: "base"}, + }, + } + mode := resolveMode(cfg, "child") + if mode != ModeStrict { + t.Errorf("expected ModeStrict for extended profile, got %v", mode) + } +} + +// TestResolveMode_EmptyMode tests default mode for empty mode string. +func TestResolveMode_EmptyMode(t *testing.T) { + cfg := TOMLConfig{ + Profiles: map[string]ProfileConfig{ + "custom": {Mode: ""}, + }, + } + mode := resolveMode(cfg, "custom") + if mode != ModeWorkspace { + t.Errorf("expected ModeWorkspace for empty mode, got %v", mode) + } +} + +// TestWeaker tests the weaker isolation comparison. +func TestWeaker(t *testing.T) { + tests := []struct { + a, b Mode + expected bool + }{ + {ModeOff, ModeWorkspace, true}, + {ModeWorkspace, ModeStrict, true}, + {ModeOff, ModeStrict, true}, + {ModeWorkspace, ModeOff, false}, + {ModeStrict, ModeWorkspace, false}, + {ModeWorkspace, ModeWorkspace, false}, + {ModeOff, ModeOff, false}, + {ModeStrict, ModeStrict, false}, + } + + for _, tc := range tests { + result := weaker(tc.a, tc.b) + if result != tc.expected { + t.Errorf("weaker(%v, %v) = %v, want %v", tc.a, tc.b, result, tc.expected) + } + } +} + +// TestEffectiveFrom_StrictMode tests strict mode config. +func TestEffectiveFrom_StrictMode(t *testing.T) { + cfg := TOMLConfig{ + Profile: "strict", + } + eff, err := EffectiveFrom(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if eff.Mode != ModeStrict { + t.Errorf("expected ModeStrict, got %v", eff.Mode) + } +} + +// TestEffectiveFrom_AllowNetworkOverride tests network override. +func TestEffectiveFrom_AllowNetworkOverride(t *testing.T) { + cfg := TOMLConfig{ + Profile: "strict", + Profiles: map[string]ProfileConfig{ + "strict": { + Mode: "strict", + AllowNetwork: boolPtr(false), + }, + }, + } + eff, err := EffectiveFrom(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if eff.AllowNetwork { + t.Error("expected AllowNetwork to be false") + } +} + +// TestEffectiveFrom_Extends tests profile extension in EffectiveFrom. +func TestEffectiveFrom_Extends(t *testing.T) { + cfg := TOMLConfig{ + Profile: "child", + Profiles: map[string]ProfileConfig{ + "base": { + Mode: "strict", + AllowNetwork: boolPtr(false), + DenyGlobs: []string{"*.log"}, + }, + "child": { + Extends: "base", + Mode: "", // empty mode inherits from base + }, + }, + } + eff, err := EffectiveFrom(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if eff.Mode != ModeStrict { + t.Errorf("expected ModeStrict, got %v", eff.Mode) + } + if eff.AllowNetwork { + t.Error("expected AllowNetwork to be false (inherited from base)") + } +} + +// TestEffectiveFrom_DenyGlobs tests deny glob inheritance. +func TestEffectiveFrom_DenyGlobs(t *testing.T) { + cfg := TOMLConfig{ + DenyGlobs: []string{"*.tmp"}, + Profiles: map[string]ProfileConfig{ + "custom": { + Mode: "workspace", + DenyGlobs: []string{"*.log"}, + }, + }, + Profile: "custom", + } + eff, err := EffectiveFrom(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(eff.DenyGlobs) != 2 { + t.Errorf("expected 2 deny globs, got %d: %v", len(eff.DenyGlobs), eff.DenyGlobs) + } +} + +// TestLoadTOML_ParseError verifies parsing errors are returned. +func TestLoadTOML_ParseError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sandbox.toml") + // Invalid TOML - missing quote + content := `foo = bar` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadTOML(path) + if err == nil { + t.Error("expected parse error but got none") + } +} + +// TestPathDenied tests path denial matching. +func TestPathDenied(t *testing.T) { + eff := Effective{ + DenyGlobs: []string{"*.env", "*.log"}, + } + + tests := []struct { + path string + expected bool + }{ + {".env", true}, + {"test.log", true}, + {"main.go", false}, + {"config.json", false}, + } + + for _, tc := range tests { + result := eff.PathDenied(tc.path) + if result != tc.expected { + t.Errorf("PathDenied(%q) = %v, want %v", tc.path, result, tc.expected) + } + } +} + +// TestPathDenied_EmptyGlobs tests with no deny globs. +func TestPathDenied_EmptyGlobs(t *testing.T) { + eff := Effective{} + if eff.PathDenied(".env") { + t.Error("expected PathDenied to return false with empty globs") + } +} + +// TestThreatLevel_String_Extra tests the ThreatLevel String method. +func TestThreatLevel_String_Extra(t *testing.T) { + tests := []struct { + level ThreatLevel + expected string + }{ + {ThreatLow, "LOW"}, + {ThreatMedium, "MEDIUM"}, + {ThreatHigh, "HIGH"}, + {ThreatCritical, "CRITICAL"}, + {ThreatLevel(999), "NONE"}, + } + + for _, tc := range tests { + result := tc.level.String() + if result != tc.expected { + t.Errorf("String() = %q, want %q", result, tc.expected) + } + } +} + +func boolPtr(b bool) *bool { + return &b +} + +// TestMergeConfigs_UserOverridesDefaultProfile tests user profile setting. +func TestMergeConfigs_UserOverridesDefaultProfile(t *testing.T) { + user := TOMLConfig{Profile: ProfileStrict} + project := TOMLConfig{} + merged, err := MergeConfigs(user, project) + if err != nil { + t.Fatal(err) + } + if merged.Profile != string(ProfileStrict) { + t.Errorf("expected Profile 'strict', got %q", merged.Profile) + } +} + +// TestMergeConfigs_ProfileWeakeningProtection protects against weakening. +func TestMergeConfigs_ProfileWeakeningProtection(t *testing.T) { + user := TOMLConfig{Profile: ProfileStrict} + project := TOMLConfig{Profile: ProfileOff} // weaker than strict + _, err := MergeConfigs(user, project) + if err == nil { + t.Fatal("expected error for weaker project profile") + } +} + +// TestMergeConfigs_DenyGlobsAppended appends deny globs correctly. +func TestMergeConfigs_DenyGlobsAppended(t *testing.T) { + user := TOMLConfig{DenyGlobs: []string{"*.tmp"}} + project := TOMLConfig{DenyGlobs: []string{"*.log"}} + merged, err := MergeConfigs(user, project) + if err != nil { + t.Fatal(err) + } + if len(merged.DenyGlobs) != 2 { + t.Errorf("expected 2 deny globs, got %d", len(merged.DenyGlobs)) + } +} + +// TestResolveMode_BuiltInFallback returns ParseMode result for unknown profile names. +func TestResolveMode_BuiltInFallback(t *testing.T) { + cfg := TOMLConfig{} + mode := resolveMode(cfg, "devbox") + // Unknown profile falls through to ParseMode which returns strict by default + if mode != ModeStrict { + t.Errorf("expected ModeStrict for unknown devbox, got %v", mode) + } +} diff --git a/internal/snapshot/snapshot_extra_test.go b/internal/snapshot/snapshot_extra_test.go new file mode 100644 index 00000000..b4531865 --- /dev/null +++ b/internal/snapshot/snapshot_extra_test.go @@ -0,0 +1,617 @@ +package snapshot + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// TestTracker_Cleanup tests the Cleanup function which runs git gc. +func TestTracker_Cleanup(t *testing.T) { + // Create a temp project dir + projectDir := t.TempDir() + + // Create a file in the project + if err := os.WriteFile(filepath.Join(projectDir, "test.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + tracker := New(projectDir) + if err := tracker.Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Take a snapshot + if _, err := tracker.Track("test snapshot"); err != nil { + t.Fatalf("Track failed: %v", err) + } + + // Cleanup should succeed + if err := tracker.Cleanup(24 * time.Hour); err != nil { + t.Errorf("Cleanup failed: %v", err) + } +} + +// TestTracker_Cleanup_NoRepo tests Cleanup when there's no git repo. +func TestTracker_Cleanup_NoRepo(t *testing.T) { + tracker := New(t.TempDir()) + // Don't init - should fail because git commands will fail without a repo + err := tracker.Cleanup(24 * time.Hour) + // Cleanup might succeed if git commands don't fail (e.g., reflog expire on empty repo) + // Just verify it doesn't panic + _ = err +} + +// TestSnapshotStore_Save tests the Save method. +func TestSnapshotStore_Save(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + snap := &WorkspaceSnapshot{ + ID: "test-id-123456", + Name: "test-snapshot", + Description: "test description", + Files: make(map[string]FileState), + CreatedAt: time.Now(), + } + + if err := store.Save(snap); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Verify file exists + path := filepath.Join(dir, "test-id-123456.json.gz") + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("expected snapshot file to exist") + } +} + +// TestSnapshotStore_Get tests the Get method. +func TestSnapshotStore_Get(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + snap := &WorkspaceSnapshot{ + ID: "test-id-123456", + Name: "test-snapshot", + Description: "test description", + Files: make(map[string]FileState), + CreatedAt: time.Now(), + } + + if err := store.Save(snap); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := store.Get("test-id-123456") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if loaded.Name != "test-snapshot" { + t.Errorf("expected name 'test-snapshot', got %q", loaded.Name) + } +} + +// TestSnapshotStore_Get_NonExistent tests Get with a non-existent snapshot. +func TestSnapshotStore_Get_NonExistent(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + _, err := store.Get("non-existent-id") + if err == nil { + t.Error("expected error for non-existent snapshot") + } +} + +// TestSnapshotStore_Delete tests the Delete method. +func TestSnapshotStore_Delete(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + snap := &WorkspaceSnapshot{ + ID: "test-id-123456", + Name: "test-snapshot", + Files: make(map[string]FileState), + CreatedAt: time.Now(), + } + + if err := store.Save(snap); err != nil { + t.Fatalf("Save failed: %v", err) + } + + if err := store.Delete("test-id-123456"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify file is gone + path := filepath.Join(dir, "test-id-123456.json.gz") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("expected file to be deleted") + } +} + +// TestSnapshotStore_Delete_NonExistent tests Delete with a non-existent snapshot. +func TestSnapshotStore_Delete_NonExistent(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + err := store.Delete("non-existent-id") + if err == nil { + t.Error("expected error for deleting non-existent snapshot") + } +} + +// TestSnapshotStore_Prune tests the Prune method. +func TestSnapshotStore_Prune(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + store.MaxSnapshots = 2 + + // Create 3 snapshots + for i := 0; i < 3; i++ { + snap := &WorkspaceSnapshot{ + ID: "snap-" + string(rune('a'+i)), + Name: "snapshot-" + string(rune('a'+i)), + Files: make(map[string]FileState), + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + } + if err := store.Save(snap); err != nil { + t.Fatalf("Save failed: %v", err) + } + } + + // Prune should remove the oldest + if err := store.Prune(); err != nil { + t.Fatalf("Prune failed: %v", err) + } + + // Should have 2 snapshots left + list, err := store.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 2 { + t.Errorf("expected 2 snapshots after prune, got %d", len(list)) + } +} + +// TestSnapshotStore_Prune_UnderLimit tests Prune when under the limit. +func TestSnapshotStore_Prune_UnderLimit(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + store.MaxSnapshots = 5 + + // Create 2 snapshots + for i := 0; i < 2; i++ { + snap := &WorkspaceSnapshot{ + ID: "snap-" + string(rune('a'+i)), + Name: "snapshot-" + string(rune('a'+i)), + Files: make(map[string]FileState), + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + } + if err := store.Save(snap); err != nil { + t.Fatalf("Save failed: %v", err) + } + } + + // Prune should do nothing + if err := store.Prune(); err != nil { + t.Fatalf("Prune failed: %v", err) + } + + list, err := store.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 2 { + t.Errorf("expected 2 snapshots, got %d", len(list)) + } +} + +// TestSnapshotStore_List_Empty tests List when no snapshots exist. +func TestSnapshotStore_List_Empty(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + list, err := store.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 0 { + t.Errorf("expected 0 snapshots, got %d", len(list)) + } +} + +// TestSnapshotStore_Diff tests the Diff method. +func TestSnapshotStore_Diff(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + // Create a project dir + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "file1.txt"), []byte("content1"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "file2.txt"), []byte("content2"), 0644); err != nil { + t.Fatal(err) + } + + // Capture a snapshot + snap, err := store.Capture(projectDir, "test", "test diff") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + + // Modify a file + if err := os.WriteFile(filepath.Join(projectDir, "file1.txt"), []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + + // Add a new file + if err := os.WriteFile(filepath.Join(projectDir, "file3.txt"), []byte("content3"), 0644); err != nil { + t.Fatal(err) + } + + // Delete a file + if err := os.Remove(filepath.Join(projectDir, "file2.txt")); err != nil { + t.Fatal(err) + } + + // Diff should show changes + diff, err := store.Diff(snap.ID, projectDir) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if len(diff.Added) != 1 { + t.Errorf("expected 1 added file, got %d", len(diff.Added)) + } + if len(diff.Modified) != 1 { + t.Errorf("expected 1 modified file, got %d", len(diff.Modified)) + } + if len(diff.Deleted) != 1 { + t.Errorf("expected 1 deleted file, got %d", len(diff.Deleted)) + } + if diff.Unchanged != 0 { + t.Errorf("expected 0 unchanged files, got %d", diff.Unchanged) + } +} + +// TestSnapshotStore_Diff_NonExistent tests Diff with a non-existent snapshot. +func TestSnapshotStore_Diff_NonExistent(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + _, err := store.Diff("non-existent", t.TempDir()) + if err == nil { + t.Error("expected error for non-existent snapshot") + } +} + +// TestSnapshotStore_Restore tests the Restore method. +func TestSnapshotStore_Restore(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + // Create a project dir + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "file1.txt"), []byte("content1"), 0644); err != nil { + t.Fatal(err) + } + + // Capture a snapshot + snap, err := store.Capture(projectDir, "test", "test restore") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + + // Modify the file + if err := os.WriteFile(filepath.Join(projectDir, "file1.txt"), []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + + // Add a new file + if err := os.WriteFile(filepath.Join(projectDir, "file2.txt"), []byte("content2"), 0644); err != nil { + t.Fatal(err) + } + + // Restore the snapshot + if err := store.Restore(snap.ID, projectDir); err != nil { + t.Fatalf("Restore failed: %v", err) + } + + // Verify file1.txt is restored + content, err := os.ReadFile(filepath.Join(projectDir, "file1.txt")) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(content) != "content1" { + t.Errorf("expected 'content1', got %q", string(content)) + } + + // Verify file2.txt is deleted + if _, err := os.Stat(filepath.Join(projectDir, "file2.txt")); !os.IsNotExist(err) { + t.Error("expected file2.txt to be deleted after restore") + } +} + +// TestSnapshotStore_Restore_NonExistent tests Restore with a non-existent snapshot. +func TestSnapshotStore_Restore_NonExistent(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + err := store.Restore("non-existent", t.TempDir()) + if err == nil { + t.Error("expected error for non-existent snapshot") + } +} + +// TestFormatList tests the FormatList function. +func TestFormatList(t *testing.T) { + // Empty list + result := FormatList(nil) + if result != "No snapshots found." { + t.Errorf("expected 'No snapshots found.', got %q", result) + } + + // Non-empty list + snaps := []*WorkspaceSnapshot{ + {ID: "abc123def456", Name: "test", CreatedAt: time.Now(), FileCount: 5, Size: 1024}, + } + result = FormatList(snaps) + if result == "" { + t.Error("expected non-empty result") + } +} + +// TestFormatDiff tests the FormatDiff function. +func TestFormatDiff(t *testing.T) { + diff := &SnapshotDiff{ + Added: []string{"file1.txt"}, + Modified: []string{"file2.txt"}, + Deleted: []string{"file3.txt"}, + Unchanged: 3, + } + result := FormatDiff(diff, "test-snapshot") + if result == "" { + t.Error("expected non-empty result") + } +} + +// TestFormatAge tests the formatAge function. +func TestFormatAge(t *testing.T) { + tests := []struct { + d time.Duration + expected string + }{ + {30 * time.Second, "just now"}, + {5 * time.Minute, "5m ago"}, + {2 * time.Hour, "2h ago"}, + {48 * time.Hour, "2d ago"}, + } + + for _, tt := range tests { + result := formatAge(tt.d) + if result != tt.expected { + t.Errorf("formatAge(%v) = %q, want %q", tt.d, result, tt.expected) + } + } +} + +// TestFormatSize tests the formatSize function. +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + expected string + }{ + {500, "500B"}, + {2048, "2KB"}, + {1048576, "1.0MB"}, + } + + for _, tt := range tests { + result := formatSize(tt.bytes) + if result != tt.expected { + t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, result, tt.expected) + } + } +} + +// TestPluralize tests the pluralize function. +func TestPluralize(t *testing.T) { + if pluralize("file", 1) != "file" { + t.Error("expected 'file' for count 1") + } + if pluralize("file", 2) != "files" { + t.Error("expected 'files' for count 2") + } +} + +// TestGenerateID tests the generateID function. +func TestGenerateID(t *testing.T) { + id1 := generateID() + id2 := generateID() + + if len(id1) != 12 { + t.Errorf("expected ID length 12, got %d", len(id1)) + } + if id1 == id2 { + t.Error("expected different IDs") + } +} + +// TestRemoveEmptyParents tests the removeEmptyParents function. +func TestRemoveEmptyParents(t *testing.T) { + // Create a nested empty directory structure + base := t.TempDir() + nested := filepath.Join(base, "a", "b", "c") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + + // removeEmptyParents should remove empty dirs up to base + removeEmptyParents(nested, base) + + // The nested dir should be removed + if _, err := os.Stat(nested); !os.IsNotExist(err) { + t.Error("expected nested dir to be removed") + } +} + +// TestRemoveEmptyParents_NonEmpty tests removeEmptyParents with non-empty dirs. +func TestRemoveEmptyParents_NonEmpty(t *testing.T) { + base := t.TempDir() + nested := filepath.Join(base, "a", "b", "c") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + + // Create a file in the nested dir + if err := os.WriteFile(filepath.Join(nested, "file.txt"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + // removeEmptyParents should not remove non-empty dirs + removeEmptyParents(nested, base) + + if _, err := os.Stat(nested); os.IsNotExist(err) { + t.Error("expected non-empty dir to NOT be removed") + } +} + +// TestGitCurrentBranch tests gitCurrentBranch with a non-git directory. +func TestGitCurrentBranch_NonGit(t *testing.T) { + result := gitCurrentBranch(t.TempDir()) + if result != "" { + t.Errorf("expected empty string for non-git dir, got %q", result) + } +} + +// TestGitCurrentCommit tests gitCurrentCommit with a non-git directory. +func TestGitCurrentCommit_NonGit(t *testing.T) { + result := gitCurrentCommit(t.TempDir()) + if result != "" { + t.Errorf("expected empty string for non-git dir, got %q", result) + } +} + +// TestSnapshotStore_Capture_WithIgnoredDirs tests that ignored directories are skipped. +func TestSnapshotStore_Capture_WithIgnoredDirs(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + projectDir := t.TempDir() + + // Create regular file + if err := os.WriteFile(filepath.Join(projectDir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + // Create ignored directory with a file + if err := os.MkdirAll(filepath.Join(projectDir, "node_modules"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "node_modules", "pkg.js"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + // Create .git directory with a file + if err := os.MkdirAll(filepath.Join(projectDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, ".git", "config"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + snap, err := store.Capture(projectDir, "test", "test ignored dirs") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + + // Should only have main.go + if len(snap.Files) != 1 { + t.Errorf("expected 1 file, got %d", len(snap.Files)) + } + if _, exists := snap.Files["main.go"]; !exists { + t.Error("expected main.go to be in snapshot") + } +} + +// TestSnapshotStore_Capture_EmptyDir tests capturing an empty directory. +func TestSnapshotStore_Capture_EmptyDir(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + projectDir := t.TempDir() + + snap, err := store.Capture(projectDir, "empty", "empty dir") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + + if snap.FileCount != 0 { + t.Errorf("expected 0 files, got %d", snap.FileCount) + } +} + +// TestSnapshotStore_Capture_OverwritesAndPrunes tests that capture prunes when over limit. +func TestSnapshotStore_Capture_OverwritesAndPrunes(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + store.MaxSnapshots = 2 + + projectDir := t.TempDir() + + // Capture 3 snapshots + for i := 0; i < 3; i++ { + if err := os.WriteFile(filepath.Join(projectDir, "file.txt"), []byte("content"), 0644); err != nil { + t.Fatal(err) + } + _, err := store.Capture(projectDir, "snap"+string(rune('a'+i)), "test") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + } + + // Should have 2 snapshots (pruned) + list, err := store.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 2 { + t.Errorf("expected 2 snapshots, got %d", len(list)) + } +} + +// TestSnapshotStore_Get_WithContent tests Get returns full file content. +func TestSnapshotStore_Get_WithContent(t *testing.T) { + dir := t.TempDir() + store := NewSnapshotStore(dir) + + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "test.txt"), []byte("file content"), 0644); err != nil { + t.Fatal(err) + } + + snap, err := store.Capture(projectDir, "test", "content test") + if err != nil { + t.Fatalf("Capture failed: %v", err) + } + + loaded, err := store.Get(snap.ID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(loaded.Files) != 1 { + t.Errorf("expected 1 file, got %d", len(loaded.Files)) + } + + fs, ok := loaded.Files["test.txt"] + if !ok { + t.Fatal("expected test.txt in files") + } + if string(fs.Content) != "file content" { + t.Errorf("expected 'file content', got %q", string(fs.Content)) + } +} diff --git a/internal/storage/paths_extra_test.go b/internal/storage/paths_extra_test.go new file mode 100644 index 00000000..6b634ae9 --- /dev/null +++ b/internal/storage/paths_extra_test.go @@ -0,0 +1,319 @@ +package storage + +import ( + "path/filepath" + "strings" + "testing" +) + +// --- sanitizeName tests --- + +func TestSanitizeName_ValidName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"simple", "simple"}, + {"with-dash", "with-dash"}, + {"with_underscore", "with_underscore"}, + {"with.dot", "with.dot"}, + {"MixedCase", "MixedCase"}, + {"with spaces", "with-spaces"}, + {"with@special#chars", "with-special-chars"}, + {"123numbers", "123numbers"}, + {"", "project"}, // empty becomes "project" + {"...", "project"}, // only dots becomes "project" + {"---", "project"}, // only dashes becomes "project" + {" ", "project"}, // only spaces becomes "project" + {".-.", "project"}, // mixed dots and dashes becomes "project" + {"valid.name-123", "valid.name-123"}, + {"UPPER", "UPPER"}, + {"a", "a"}, + } + + for _, tt := range tests { + got := sanitizeName(tt.input) + if got != tt.want { + t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// --- cleanEnvDir tests --- + +func TestCleanEnvDir_Set(t *testing.T) { + t.Setenv("TEST_VAR", "/some/path") + got := cleanEnvDir("TEST_VAR") + if got != "/some/path" { + t.Errorf("cleanEnvDir() = %q, want %q", got, "/some/path") + } +} + +func TestCleanEnvDir_Unset(t *testing.T) { + t.Setenv("TEST_VAR", "") + got := cleanEnvDir("TEST_VAR") + if got != "" { + t.Errorf("cleanEnvDir() = %q, want empty string", got) + } +} + +func TestCleanEnvDir_Whitespace(t *testing.T) { + t.Setenv("TEST_VAR", " /path/with/spaces ") + got := cleanEnvDir("TEST_VAR") + if got != "/path/with/spaces" { + t.Errorf("cleanEnvDir() = %q, want %q", got, "/path/with/spaces") + } +} + +// --- ProjectID tests --- + +func TestProjectID_EmptyPath(t *testing.T) { + id := ProjectID("") + if id == "" { + t.Error("ProjectID('') should return non-empty string") + } + // Empty string becomes "." which uses current directory name as base + if !strings.Contains(id, "-") { + t.Errorf("ProjectID('') = %q, want format 'base-hash'", id) + } +} + +func TestProjectID_DotPath(t *testing.T) { + id := ProjectID(".") + if id == "" { + t.Error("ProjectID('.') should return non-empty string") + } + // Uses current directory name as base + if !strings.Contains(id, "-") { + t.Errorf("ProjectID('.') = %q, want format 'base-hash'", id) + } +} + +func TestProjectID_RootPath(t *testing.T) { + id := ProjectID("/") + if id == "" { + t.Error("ProjectID('/') should return non-empty string") + } + if !strings.HasPrefix(id, "project-") { + t.Errorf("ProjectID('/') = %q, want prefix 'project-'", id) + } +} + +func TestProjectID_DifferentPaths(t *testing.T) { + id1 := ProjectID("/path/to/project1") + id2 := ProjectID("/path/to/project2") + if id1 == id2 { + t.Error("Different paths should produce different IDs") + } +} + +func TestProjectID_RelativeVsAbsolute(t *testing.T) { + // Same directory accessed via relative and absolute path should produce same ID + abs, err := filepath.Abs(".") + if err != nil { + t.Skip("Cannot get absolute path") + } + id1 := ProjectID(".") + id2 := ProjectID(abs) + if id1 != id2 { + t.Errorf("Relative and absolute paths should produce same ID: %q vs %q", id1, id2) + } +} + +func TestProjectID_Format(t *testing.T) { + id := ProjectID("/some/project") + // Should be: sanitized-base + "-" + 12 hex chars + parts := strings.SplitN(id, "-", 2) + if len(parts) < 2 { + t.Fatalf("ProjectID() = %q, want format 'base-hash'", id) + } + hash := parts[len(parts)-1] + if len(hash) != projectIDHashLen { + t.Errorf("ProjectID() hash length = %d, want %d", len(hash), projectIDHashLen) + } + // Hash should be valid hex + for _, c := range hash { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("ProjectID() hash contains invalid hex char: %c", c) + } + } +} + +// --- Directory path tests --- + +func TestSessionsDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := SessionsDir() + want := filepath.Join(stateDir, "sessions") + if got != want { + t.Errorf("SessionsDir() = %q, want %q", got, want) + } +} + +func TestDaemonRunDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := DaemonRunDir() + want := filepath.Join(stateDir, "run") + if got != want { + t.Errorf("DaemonRunDir() = %q, want %q", got, want) + } +} + +func TestWorkspaceSnapshotsDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := WorkspaceSnapshotsDir() + want := filepath.Join(stateDir, "snapshots") + if got != want { + t.Errorf("WorkspaceSnapshotsDir() = %q, want %q", got, want) + } +} + +func TestBundledSkillsDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := BundledSkillsDir() + want := filepath.Join(stateDir, "bundled-skills") + if got != want { + t.Errorf("BundledSkillsDir() = %q, want %q", got, want) + } +} + +func TestPersonasDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := PersonasDir() + want := filepath.Join(stateDir, "agents") + if got != want { + t.Errorf("PersonasDir() = %q, want %q", got, want) + } +} + +func TestTasteDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + + got := TasteDir() + want := filepath.Join(stateDir, "taste") + if got != want { + t.Errorf("TasteDir() = %q, want %q", got, want) + } +} + +func TestPlansDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + t.Setenv(envStateDir, stateDir) + project := filepath.Join(t.TempDir(), "myproject") + + got := PlansDir(project) + id := ProjectID(project) + want := filepath.Join(stateDir, "projects", id, "plans") + if got != want { + t.Errorf("PlansDir() = %q, want %q", got, want) + } +} + +func TestRepoMapCacheDir(t *testing.T) { + cacheDir := filepath.Join(t.TempDir(), "cache") + t.Setenv(envCacheDir, cacheDir) + project := filepath.Join(t.TempDir(), "myproject") + + got := RepoMapCacheDir(project) + id := ProjectID(project) + want := filepath.Join(cacheDir, "projects", id, "repomap") + if got != want { + t.Errorf("RepoMapCacheDir() = %q, want %q", got, want) + } +} + +// --- ConfigDir/StateDir/CacheDir fallback tests --- + +func TestConfigDir_Default(t *testing.T) { + // Clear env var to test default behavior + t.Setenv(envConfigDir, "") + t.Setenv(envEyrieConfigDir, "") + + got := ConfigDir() + if got == "" { + t.Error("ConfigDir() should return non-empty string") + } + // Should end with "hawk" + if !strings.HasSuffix(got, "hawk") { + t.Errorf("ConfigDir() = %q, want suffix 'hawk'", got) + } +} + +func TestStateDir_Default(t *testing.T) { + t.Setenv(envStateDir, "") + + got := StateDir() + if got == "" { + t.Error("StateDir() should return non-empty string") + } +} + +func TestCacheDir_Default(t *testing.T) { + t.Setenv(envCacheDir, "") + + got := CacheDir() + if got == "" { + t.Error("CacheDir() should return non-empty string") + } +} + +// --- SettingsPath tests --- + +func TestSettingsPath(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "config") + t.Setenv(envConfigDir, configDir) + + got := SettingsPath() + want := filepath.Join(configDir, "settings.json") + if got != want { + t.Errorf("SettingsPath() = %q, want %q", got, want) + } +} + +// --- ProviderConfigPath tests --- + +func TestProviderConfigPath_EyrieOverride(t *testing.T) { + eyrieDir := filepath.Join(t.TempDir(), "eyrie") + t.Setenv(envEyrieConfigDir, eyrieDir) + + got := ProviderConfigPath() + want := filepath.Join(eyrieDir, "provider.json") + if got != want { + t.Errorf("ProviderConfigPath() = %q, want %q", got, want) + } +} + +func TestProviderConfigPath_HawkFallback(t *testing.T) { + hawkDir := filepath.Join(t.TempDir(), "hawk") + t.Setenv(envConfigDir, hawkDir) + t.Setenv(envEyrieConfigDir, "") + + got := ProviderConfigPath() + want := filepath.Join(hawkDir, "provider.json") + if got != want { + t.Errorf("ProviderConfigPath() = %q, want %q", got, want) + } +} + +func TestProviderConfigPath_EyrieWhitespaceIgnored(t *testing.T) { + hawkDir := filepath.Join(t.TempDir(), "hawk") + t.Setenv(envConfigDir, hawkDir) + t.Setenv(envEyrieConfigDir, " ") // whitespace should be treated as empty + + got := ProviderConfigPath() + want := filepath.Join(hawkDir, "provider.json") + if got != want { + t.Errorf("ProviderConfigPath() = %q, want %q", got, want) + } +} diff --git a/internal/tools/lsp_tools_metadata_test.go b/internal/tools/lsp_tools_metadata_test.go new file mode 100644 index 00000000..6a629426 --- /dev/null +++ b/internal/tools/lsp_tools_metadata_test.go @@ -0,0 +1,198 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" +) + +// TestLSPTools_Metadata verifies that all LSP tool implementations return +// non-empty metadata (Name, Description, RiskLevel, Parameters). +func TestLSPTools_Metadata(t *testing.T) { + mgr := newTestManager() + defer mgr.Close() + + tools := []struct { + name string + tool interface { + Name() string + Description() string + RiskLevel() string + Parameters() map[string]interface{} + } + }{ + {"GotoDefinition", &LSPGotoDefinitionTool{Manager: mgr}}, + {"FindReferences", &LSPFindReferencesTool{Manager: mgr}}, + {"Symbols", &LSPSymbolsTool{Manager: mgr}}, + {"PrepareRename", &LSPPrepareRenameTool{Manager: mgr}}, + {"Rename", &LSPRenameTool{Manager: mgr}}, + } + + for _, tc := range tools { + t.Run(tc.name, func(t *testing.T) { + if name := tc.tool.Name(); name == "" { + t.Error("Name() returned empty string") + } + if desc := tc.tool.Description(); desc == "" { + t.Error("Description() returned empty string") + } + if risk := tc.tool.RiskLevel(); risk == "" { + t.Error("RiskLevel() returned empty string") + } + params := tc.tool.Parameters() + if params == nil { + t.Fatal("Parameters() returned nil") + } + if _, ok := params["type"]; !ok { + t.Error("Parameters() missing 'type' field") + } + if _, ok := params["properties"]; !ok { + t.Error("Parameters() missing 'properties' field") + } + if _, ok := params["required"]; !ok { + t.Error("Parameters() missing 'required' field") + } + }) + } +} + +// TestLSPTools_Execute_NoServer verifies that tools return a helpful message +// when no LSP server is configured for the file type. +func TestLSPTools_Execute_NoServer(t *testing.T) { + mgr := newTestManager() + defer mgr.Close() + + ctx := context.Background() + + // GotoDefinition with unknown file type + t.Run("GotoDefinition_NoServer", func(t *testing.T) { + tool := &LSPGotoDefinitionTool{Manager: mgr} + input, _ := json.Marshal(map[string]interface{}{ + "path": "test.unknownext", + "line": 1, + "character": 0, + }) + result, err := tool.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result for no server") + } + }) + + // FindReferences with unknown file type + t.Run("FindReferences_NoServer", func(t *testing.T) { + tool := &LSPFindReferencesTool{Manager: mgr} + input, _ := json.Marshal(map[string]interface{}{ + "path": "test.unknownext", + "line": 1, + "character": 0, + }) + result, err := tool.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result for no server") + } + }) + + // Symbols with unknown file type + t.Run("Symbols_NoServer", func(t *testing.T) { + tool := &LSPSymbolsTool{Manager: mgr} + input, _ := json.Marshal(map[string]interface{}{ + "path": "test.unknownext", + }) + result, err := tool.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result for no server") + } + }) + + // PrepareRename with unknown file type + t.Run("PrepareRename_NoServer", func(t *testing.T) { + tool := &LSPPrepareRenameTool{Manager: mgr} + input, _ := json.Marshal(map[string]interface{}{ + "path": "test.unknownext", + "line": 1, + "character": 0, + }) + result, err := tool.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result for no server") + } + }) + + // Rename with unknown file type + t.Run("Rename_NoServer", func(t *testing.T) { + tool := &LSPRenameTool{Manager: mgr} + input, _ := json.Marshal(map[string]interface{}{ + "path": "test.unknownext", + "line": 1, + "character": 0, + "new_name": "newName", + }) + result, err := tool.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result for no server") + } + }) +} + +// TestLSPTools_Execute_InvalidInput verifies that tools handle invalid JSON input gracefully. +func TestLSPTools_Execute_InvalidInput(t *testing.T) { + mgr := newTestManager() + defer mgr.Close() + + ctx := context.Background() + + t.Run("GotoDefinition_InvalidJSON", func(t *testing.T) { + tool := &LSPGotoDefinitionTool{Manager: mgr} + _, err := tool.Execute(ctx, json.RawMessage("not valid json")) + if err == nil { + t.Error("expected error for invalid JSON input") + } + }) + + t.Run("FindReferences_InvalidJSON", func(t *testing.T) { + tool := &LSPFindReferencesTool{Manager: mgr} + _, err := tool.Execute(ctx, json.RawMessage("not valid json")) + if err == nil { + t.Error("expected error for invalid JSON input") + } + }) + + t.Run("Symbols_InvalidJSON", func(t *testing.T) { + tool := &LSPSymbolsTool{Manager: mgr} + _, err := tool.Execute(ctx, json.RawMessage("not valid json")) + if err == nil { + t.Error("expected error for invalid JSON input") + } + }) + + t.Run("PrepareRename_InvalidJSON", func(t *testing.T) { + tool := &LSPPrepareRenameTool{Manager: mgr} + _, err := tool.Execute(ctx, json.RawMessage("not valid json")) + if err == nil { + t.Error("expected error for invalid JSON input") + } + }) + + t.Run("Rename_InvalidJSON", func(t *testing.T) { + tool := &LSPRenameTool{Manager: mgr} + _, err := tool.Execute(ctx, json.RawMessage("not valid json")) + if err == nil { + t.Error("expected error for invalid JSON input") + } + }) +}