diff --git a/internal/mcp_handlers/tools.go b/internal/mcp_handlers/tools.go index 3cb61e9..e9bc02c 100644 --- a/internal/mcp_handlers/tools.go +++ b/internal/mcp_handlers/tools.go @@ -51,6 +51,7 @@ func RegisterTools( registerTool(s, tools.getSelectorByParametersTool) registerTool(s, tools.getAllSelectorsTool) registerTool(s, tools.updateHealingResultTool) + registerTool(s, tools.saveSelectorPathTool) } func (ti *McpTool) getLastReportTool() (*mcp.Tool, ToolHandler[any, any]) { @@ -371,3 +372,153 @@ func (ti *McpTool) updateHealingResultTool() (*mcp.Tool, ToolHandler[map[string] }, nil, nil } } + +func (ti *McpTool) saveSelectorPathTool() (*mcp.Tool, ToolHandler[map[string]any, any]) { + return &mcp.Tool{ + Name: "saveSelectorPath", + Description: "Saves the path to a selector in Healenium. This endpoint associates healing results with their declaring classes for a specific selector.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": { + Type: "string", + Description: "Unique identifier of the selector (MD5 hash format)", + Pattern: "^[0-9a-fA-F]{32}$", + }, + "data": { + Type: "array", + Description: "List of records containing healing result IDs and their declaring classes (must contain at least one record)", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "healingResultId": { + Type: "integer", + Description: "Healing result ID", + }, + "declaringClass": { + Type: "string", + Description: "Class path for selector (e.g., 'com.example.tests.LoginTest')", + }, + }, + Required: []string{"healingResultId", "declaringClass"}, + }, + }, + }, + Required: []string{"id", "data"}, + }, + }, + func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + // Extract and validate id + id, ok := args["id"].(string) + if !ok || id == "" { + return nil, nil, fmt.Errorf("id is required and must be a non-empty string") + } + + // Validate that id is a valid 32-character hex string + if !uid.IsValidHexString(id, 32) { + return nil, nil, fmt.Errorf( + "invalid id: must be a 32-character hexadecimal string (MD5 hash format)", + ) + } + + // Extract and validate data array + dataRaw, ok := args["data"] + if !ok { + return nil, nil, fmt.Errorf("data is required") + } + + dataArray, ok := dataRaw.([]any) + if !ok { + return nil, nil, fmt.Errorf("data must be an array") + } + + if len(dataArray) == 0 { + return nil, nil, fmt.Errorf("data array must contain at least one record") + } + + // Process and validate each data record + processedData := make([]map[string]any, 0, len(dataArray)) + for i, item := range dataArray { + itemMap, ok := item.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("data[%d] must be an object", i) + } + + // Extract and validate healingResultId + var healingResultId uint64 + switch v := itemMap["healingResultId"].(type) { + case float64: + if v < 0 || v != float64(uint64(v)) { + return nil, nil, fmt.Errorf( + "data[%d].healingResultId must be a non-negative integer (got %v)", + i, + v, + ) + } + healingResultId = uint64(v) + case uint64: + healingResultId = v + default: + return nil, nil, fmt.Errorf( + "data[%d].healingResultId is required and must be an unsigned integer", + i, + ) + } + + // Extract and validate declaringClass + declaringClass, ok := itemMap["declaringClass"].(string) + if !ok || declaringClass == "" { + return nil, nil, fmt.Errorf( + "data[%d].declaringClass is required and must be a non-empty string", + i, + ) + } + + processedData = append(processedData, map[string]any{ + "healingResultId": healingResultId, + "declaringClass": declaringClass, + }) + } + + // Build request body + requestBody := map[string]any{ + "id": id, + "data": processedData, + } + + // Create a new HTTP POST request + resp, err := ti.client.R(). + SetContext(ctx). + SetBody(requestBody). + Post("/healenium/selector/save/path") + if err != nil { + return nil, nil, fmt.Errorf( + "failed to save selector path at /healenium/selector/save/path: %w", + err, + ) + } + + // Check HTTP status code + if !resp.IsSuccess() { + return nil, nil, fmt.Errorf( + "failed to save selector path: received status code %d, body: %s", + resp.StatusCode(), + resp.String(), + ) + } + + // Create success message + successMessage := fmt.Sprintf( + "Selector path saved successfully for id %s with %d record(s) (HTTP %d)", + id, + len(processedData), + resp.StatusCode(), + ) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: successMessage}, + }, + }, nil, nil + } +} diff --git a/internal/mcp_handlers/tools_test.go b/internal/mcp_handlers/tools_test.go index befa240..c2bc0fc 100644 --- a/internal/mcp_handlers/tools_test.go +++ b/internal/mcp_handlers/tools_test.go @@ -2,6 +2,7 @@ package mcp_handlers import ( "context" + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -1458,3 +1459,460 @@ func TestGetAllSelectorsTool_Timeout(t *testing.T) { t.Errorf("expected timeout error but got: %v", err) } } + +// TestSaveSelectorPathTool_Integration tests the complete flow of saveSelectorPathTool +// Architecture: +// LLM Mock Client -> Healenium MCP Server -> Healenium Server Mock +func TestSaveSelectorPathTool_Integration(t *testing.T) { + tests := []struct { + name string + id string + data []map[string]any + healeniumStatusCode int + expectedSuccess bool + expectedErrorContent string + expectedMessage string + }{ + { + name: "successful save with single record", + id: "d0d102b31f2f5569fca72b6b093955e6", + data: []map[string]any{ + { + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + healeniumStatusCode: http.StatusOK, + expectedSuccess: true, + expectedMessage: "Selector path saved successfully for id d0d102b31f2f5569fca72b6b093955e6 with 1 record(s) (HTTP 200)", + }, + { + name: "successful save with multiple records", + id: "a1b2c3d4e5f601234567890abcdef012", + data: []map[string]any{ + { + "healingResultId": float64(10), + "declaringClass": "com.example.tests.HomePageTest", + }, + { + "healingResultId": float64(11), + "declaringClass": "com.example.tests.CheckoutTest", + }, + { + "healingResultId": float64(12), + "declaringClass": "com.example.tests.ProfileTest", + }, + }, + healeniumStatusCode: http.StatusOK, + expectedSuccess: true, + expectedMessage: "Selector path saved successfully for id a1b2c3d4e5f601234567890abcdef012 with 3 record(s) (HTTP 200)", + }, + { + name: "server error", + id: "d0d102b31f2f5569fca72b6b093955e6", + data: []map[string]any{ + { + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + healeniumStatusCode: http.StatusInternalServerError, + expectedSuccess: false, + expectedErrorContent: "failed to save selector path", + }, + { + name: "not found error", + id: "ffffffffffffffffffffffffffffffff", + data: []map[string]any{ + { + "healingResultId": float64(999), + "declaringClass": "com.example.tests.NonExistentTest", + }, + }, + healeniumStatusCode: http.StatusNotFound, + expectedSuccess: false, + expectedErrorContent: "failed to save selector path", + }, + { + name: "bad request error", + id: "d0d102b31f2f5569fca72b6b093955e6", + data: []map[string]any{ + { + "healingResultId": float64(0), + "declaringClass": "com.example.tests.InvalidTest", + }, + }, + healeniumStatusCode: http.StatusBadRequest, + expectedSuccess: false, + expectedErrorContent: "failed to save selector path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Step 1: Create mock Healenium server + mockHealeniumServer := createMockSaveSelectorPathServer( + t, + tt.id, + tt.data, + tt.healeniumStatusCode, + ) + defer mockHealeniumServer.Close() + + // Step 2: Create Healenium MCP Server pointing to the mock backend + healeniumMcpServer := createHealeniumMCPServer(t, mockHealeniumServer.URL) + + // Step 3: Call the tool handler + _, handler := healeniumMcpServer.saveSelectorPathTool() + ctx := context.Background() + req := &mcp.CallToolRequest{} + input := map[string]any{ + "id": tt.id, + "data": convertToAnySlice(tt.data), + } + + result, _, err := handler(ctx, req, input) + + // Step 4: Verify results + if tt.expectedSuccess { + if err != nil { + t.Errorf("expected success but got error: %v", err) + } + if result == nil { + t.Fatal("expected result but got nil") + } + if len(result.Content) == 0 { + t.Fatal("expected content but got empty array") + } + + // Check the success message + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Errorf("expected TextContent but got different type") + } else if !strings.Contains(textContent.Text, tt.expectedMessage) { + t.Errorf("expected message to contain %q but got %q", tt.expectedMessage, textContent.Text) + } + } else { + if err == nil { + t.Error("expected error but got success") + } else if !contains(err.Error(), tt.expectedErrorContent) { + t.Errorf("expected error to contain %q but got %q", tt.expectedErrorContent, err.Error()) + } + } + }) + } +} + +// TestSaveSelectorPathTool_ValidationErrors tests parameter validation +func TestSaveSelectorPathTool_ValidationErrors(t *testing.T) { + tests := []struct { + name string + input map[string]any + expectedErrorContent string + }{ + { + name: "missing id", + input: map[string]any{ + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "id is required and must be a non-empty string", + }, + { + name: "empty id", + input: map[string]any{ + "id": "", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "id is required and must be a non-empty string", + }, + { + name: "invalid id format - not hex", + input: map[string]any{ + "id": "not-a-valid-hex-string-12345", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "invalid id: must be a 32-character hexadecimal string (MD5 hash format)", + }, + { + name: "invalid id format - wrong length", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b09395", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "invalid id: must be a 32-character hexadecimal string (MD5 hash format)", + }, + { + name: "missing data", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + }, + expectedErrorContent: "data is required", + }, + { + name: "data is not an array", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": "not an array", + }, + expectedErrorContent: "data must be an array", + }, + { + name: "empty data array", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{}, + }, + expectedErrorContent: "data array must contain at least one record", + }, + { + name: "data item is not an object", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + "not an object", + }, + }, + expectedErrorContent: "data[0] must be an object", + }, + { + name: "missing healingResultId", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "data[0].healingResultId is required and must be an unsigned integer", + }, + { + name: "invalid healingResultId type", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": "not a number", + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "data[0].healingResultId is required and must be an unsigned integer", + }, + { + name: "negative healingResultId", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(-1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "data[0].healingResultId must be a non-negative integer", + }, + { + name: "non-integer healingResultId", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(1.5), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + }, + expectedErrorContent: "data[0].healingResultId must be a non-negative integer", + }, + { + name: "missing declaringClass", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + }, + }, + }, + expectedErrorContent: "data[0].declaringClass is required and must be a non-empty string", + }, + { + name: "empty declaringClass", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "", + }, + }, + }, + expectedErrorContent: "data[0].declaringClass is required and must be a non-empty string", + }, + { + name: "invalid declaringClass type", + input: map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": 123, + }, + }, + }, + expectedErrorContent: "data[0].declaringClass is required and must be a non-empty string", + }, + } + + // Create a dummy server (won't actually be called due to validation errors) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer mockServer.Close() + + healeniumMcpServer := createHealeniumMCPServer(t, mockServer.URL) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, handler := healeniumMcpServer.saveSelectorPathTool() + ctx := context.Background() + req := &mcp.CallToolRequest{} + + _, _, err := handler(ctx, req, tt.input) + + if err == nil { + t.Error("expected validation error but got success") + } else if !contains(err.Error(), tt.expectedErrorContent) { + t.Errorf("expected error to contain %q but got %q", tt.expectedErrorContent, err.Error()) + } + }) + } +} + +func TestSaveSelectorPathTool_Timeout(t *testing.T) { + // Create a mock Healenium server that delays response + healeniumServerMock := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Delay response to trigger timeout + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + }), + ) + defer healeniumServerMock.Close() + + // Create MCP server with short timeout + healeniumURL, _ := url.Parse(healeniumServerMock.URL) + client := resty.New(). + SetBaseURL(healeniumURL.String()). + SetTimeout(500 * time.Millisecond) // Short timeout + + mcpTool := newMcpTool(client, nil) + _, handler := mcpTool.saveSelectorPathTool() + + // Call the tool + ctx := context.Background() + req := &mcp.CallToolRequest{} + input := map[string]any{ + "id": "d0d102b31f2f5569fca72b6b093955e6", + "data": []any{ + map[string]any{ + "healingResultId": float64(1), + "declaringClass": "com.example.tests.LoginTest", + }, + }, + } + + _, _, err := handler(ctx, req, input) + if err == nil { + t.Fatal("expected timeout error but got nil") + } + + // Verify it's a timeout-related error + if !contains(err.Error(), "failed to save selector path") { + t.Errorf("expected timeout error but got: %v", err) + } +} + +// createMockSaveSelectorPathServer creates a mock Healenium server for testing saveSelectorPath +func createMockSaveSelectorPathServer( + t *testing.T, + expectedID string, + expectedData []map[string]any, + statusCode int, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the request is to the correct endpoint + if r.URL.Path != "/healenium/selector/save/path" { + t.Errorf("expected path /healenium/selector/save/path but got %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + + // Verify it's a POST request + if r.Method != http.MethodPost { + t.Errorf("expected POST method but got %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Verify Content-Type + contentType := r.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("expected Content-Type to contain application/json but got %s", contentType) + } + + // For successful status codes, verify the request body + if statusCode == http.StatusOK { + var requestBody map[string]any + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Errorf("failed to decode request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Verify id + if id, ok := requestBody["id"].(string); !ok || id != expectedID { + t.Errorf("expected id %q but got %q", expectedID, requestBody["id"]) + } + + // Verify data array exists + if _, ok := requestBody["data"]; !ok { + t.Errorf("expected data field in request body") + } + } + + w.WriteHeader(statusCode) + })) +} + +// convertToAnySlice converts []map[string]any to []any for test input +func convertToAnySlice(input []map[string]any) []any { + result := make([]any, len(input)) + for i, item := range input { + result[i] = item + } + return result +}