blob: 901e8bc824086918b57db9ac978fdd8bbc4ebe64 [file] [log] [blame]
package chromeperf
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.skia.org/infra/go/skerr"
)
// mockRoundTripper allows you to define a function that will be called
// when the client's Do method (which uses RoundTrip) is invoked.
type mockRoundTripper struct {
roundTripFunc func(req *http.Request) (*http.Response, error)
ReceivedRequest *http.Request // To store the request for inspection
}
// RoundTrip implements the http.RoundTripper interface.
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
m.ReceivedRequest = req // Store the request for later assertions
return m.roundTripFunc(req)
}
func TestGenereateUrl_Bridge(t *testing.T) {
api := "api_name"
function := "func_name"
direct := false
urlOverride := ""
url := generateTargetUrl(urlOverride, direct, api, function)
assert.Equal(t, url, "https://skia-bridge-dot-chromeperf.appspot.com/api_name/func_name")
}
func TestGenereateUrl_Direct(t *testing.T) {
api := "api_name"
function := "func_name" // will be ignored
direct := true
urlOverride := ""
url := generateTargetUrl(urlOverride, direct, api, function)
assert.Equal(t, url, "https://chromeperf.appspot.com/api_name")
}
func TestGenereateUrl_Override(t *testing.T) {
api := "api_name" // will be ignored
function := "func_name" // will be ignored
direct := true // will be ignored
urlOverride := "override.url/someapi/andfunction"
url := generateTargetUrl(urlOverride, direct, api, function)
assert.Equal(t, url, urlOverride)
}
// NewChromePerfTestClient is a constructor for ChromePerfClient
func NewChromePerfTestClient(httpClient *http.Client, urlOverride string, directCallLegacy bool) ChromePerfClient {
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second} // Default client if none provided
}
return &chromePerfClientImpl{
httpClient: httpClient,
urlOverride: urlOverride,
directCallLegacy: directCallLegacy,
}
}
// For testing purpose only.
type getSheriffListResponse struct {
SheriffList []string `json:"sheriff_list"`
Error string `json:"error"`
}
// For testing purpose only.
type mockReadCloser struct {
readErr error
closeErr error
data []byte
readIdx int
}
// For testing purpose only.
func (m *mockReadCloser) Read(p []byte) (n int, err error) {
if m.readErr != nil {
return 0, m.readErr
}
if m.readIdx >= len(m.data) {
return 0, io.EOF
}
n = copy(p, m.data[m.readIdx:])
m.readIdx += n
return n, nil
}
// For testing purpose only.
func (m *mockReadCloser) Close() error { return m.closeErr }
func TestSendGetRequest(t *testing.T) {
tests := []struct {
name string
clientConfig func() (urlOverride string, directCallLegacy bool) // Configure client properties
apiName string
functionName string
queryParams url.Values
responseObjTemplate func() any
roundTripFunc func(t *testing.T, req *http.Request) (*http.Response, error) // Defines mock transport behavior
expectError bool
expectedErrorMsg string
expectedResponse any
}{
{
name: "Successful request",
clientConfig: func() (string, bool) { return "", false },
apiName: "sheriff_configs_skia",
functionName: "",
queryParams: url.Values{"key1": []string{"value1"}, "key2": []string{"value2"}},
responseObjTemplate: func() any { return &getSheriffListResponse{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
assert.NotNil(t, req.Context())
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "/sheriff_configs_skia/", req.URL.Path)
assert.Equal(t, "key1=value1&key2=value2", req.URL.RawQuery)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"sheriff_list":["abc", "def"], "error":""}`)),
Header: make(http.Header),
}, nil
},
expectError: false,
expectedResponse: &getSheriffListResponse{SheriffList: []string{"abc", "def"}},
},
{
name: "Successful request with URL override",
clientConfig: func() (string, bool) { return "http://mockserver.com/override", false },
apiName: "customApi",
functionName: "customFunc",
queryParams: url.Values{"k": []string{"v"}},
responseObjTemplate: func() any { return &getSheriffListResponse{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
assert.Equal(t, "https://mockedserver/customApi/customFunc?k=v", req.URL.String()) // Full URL check
assert.Equal(t, "k=v", req.URL.RawQuery)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"sheriff_list":["abc", "def"], "error":""}`)),
Header: make(http.Header),
}, nil
},
expectError: false,
expectedResponse: &getSheriffListResponse{SheriffList: []string{"abc", "def"}},
},
{
name: "Successful request with direct call legacy",
clientConfig: func() (string, bool) { return "", true }, // directCallLegacy = true
apiName: "legacyApi",
functionName: "legacyFunc",
queryParams: nil,
responseObjTemplate: func() any { return &getSheriffListResponse{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
assert.Equal(t, "https://mockedserver/legacyApi/legacyFunc?", req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"sheriff_list":["abc", "def"], "error":""}`)),
Header: make(http.Header),
}, nil
},
expectError: false,
expectedResponse: &getSheriffListResponse{SheriffList: []string{"abc", "def"}},
},
{
name: "Network error from RoundTrip",
clientConfig: func() (string, bool) { return "", false },
apiName: "anyApi",
functionName: "anyFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
return nil, errors.New("simulated network error from RoundTrip")
},
expectError: true,
expectedErrorMsg: "Failed to get chrome perf response", // Wrapped error
},
{
name: "Error reading response body (from RoundTrip's perspective)",
clientConfig: func() (string, bool) { return "", false },
apiName: "readErrorApi",
functionName: "readErrorFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: &mockReadCloser{readErr: errors.New("simulated read body error")},
Header: make(http.Header),
}, nil
},
expectError: true,
expectedErrorMsg: "Failed to read body from chrome perf response",
},
{
name: "JSON unmarshal error",
clientConfig: func() (string, bool) { return "", false },
apiName: "jsonErrorApi",
functionName: "jsonErrorFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`this is not valid json`)),
Header: make(http.Header),
}, nil
},
expectError: true,
expectedErrorMsg: "Failed to parse chrome perf response body",
},
{
name: "Non-2xx HTTP status code (e.g., 400)",
clientConfig: func() (string, bool) { return "", false },
apiName: "statusErrorApi",
functionName: "statusErrorFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest, // 400
Body: io.NopCloser(strings.NewReader(`{"error": "bad input from roundtrip"}`)),
Header: make(http.Header),
}, nil
},
expectError: true,
expectedErrorMsg: "chrome perf request failed", // Also check for status code and body in error
},
{
name: "Context cancelled during HTTP request",
clientConfig: func() (string, bool) { return "", false },
apiName: "contextApi",
functionName: "contextFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
// The actual context cancellation is handled by client.Do if the context is passed correctly.
// The RoundTripper itself might not always directly see the context unless the HTTP client
// aborts the request due to context cancellation before RoundTrip is even called or while it's running.
// For this test, we'll simulate the error that client.Do would return.
select {
case <-req.Context().Done():
return nil, req.Context().Err() // e.g. context.Canceled or context.DeadlineExceeded
default:
// If context wasn't cancelled fast enough for this check
time.Sleep(10 * time.Millisecond) // Give it a moment
if req.Context().Err() != nil {
return nil, req.Context().Err()
}
return nil, errors.New("context was not cancelled as expected by mock roundtrip")
}
},
expectError: true,
expectedErrorMsg: "Failed to get chrome perf response", // Error from GetWithContext/client.Do will be wrapped
},
{
name: "Client with nil httpClient (setup error, not RoundTripper)",
clientConfig: func() (string, bool) { return "NIL_HTTP_CLIENT", false }, // Special marker
apiName: "nilClientApi",
functionName: "nilClientFunc",
responseObjTemplate: func() any { return &struct{}{} },
roundTripFunc: func(t *testing.T, req *http.Request) (*http.Response, error) {
// This won't be called if httpClient is nil
return nil, errors.New("roundTripFunc should not be called for nil httpClient test")
},
expectError: true,
expectedErrorMsg: "no such host",
},
}
expectedURL := "https://mockedserver"
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
urlOverride, directCallLegacy := tt.clientConfig()
var testHttpClient *http.Client
var mockTransport *mockRoundTripper
if urlOverride == "NIL_HTTP_CLIENT" {
testHttpClient = &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Simulate "no such host" error
return nil, &net.DNSError{Err: "no such host", Name: strings.Split(addr, ":")[0]}
},
},
}
} else {
mockTransport = &mockRoundTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
// Pass `t` to the user-defined roundTripFunc for assertions
return tt.roundTripFunc(t, req)
},
}
testHttpClient = &http.Client{
Transport: mockTransport,
}
}
apiURL := fmt.Sprintf("%s/%s/%s", expectedURL, tt.apiName, tt.functionName)
clientUnderTest := NewChromePerfTestClient(testHttpClient, apiURL, directCallLegacy)
ctx := context.Background()
if tt.name == "Context cancelled during HTTP request" {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Millisecond) // Short timeout
defer cancel()
}
responseTemplate := tt.responseObjTemplate()
err := clientUnderTest.SendGetRequest(ctx, tt.apiName, tt.functionName, tt.queryParams, responseTemplate)
if tt.expectError {
require.Error(t, err, "Expected an error but got none")
if tt.expectedErrorMsg != "" {
assert.Contains(t, err.Error(), tt.expectedErrorMsg, "Error message mismatch: got %v", err)
}
} else {
require.NoError(t, err, "Expected no error but got one: %v", err)
if tt.expectedResponse != nil {
assert.Equal(t, tt.expectedResponse, responseTemplate, "Response data mismatch")
}
assert.NotNil(t, mockTransport.ReceivedRequest, "mockTransport did not receive any request")
}
})
}
}
// For testing purpose only.
type anomaly struct {
Id string `json:"id"`
TestPath string `json:"test_path"`
BugId int `json:"bug_id"`
StartRevision int `json:"start_revision"`
EndRevision int `json:"end_revision"`
// The hashes below are needed for cases where the commit numbers are
// different in chromeperf and in the perf instance. We can use these
// hashes to look up the correct commit number from the database.
StartRevisionHash string `json:"start_revision_hash,omitempty"`
EndRevisionHash string `json:"end_revision_hash,omitempty"`
IsImprovement bool `json:"is_improvement"`
Recovered bool `json:"recovered"`
State string `json:"state"`
Statistics string `json:"statistic"`
Unit string `json:"units"`
DegreeOfFreedom float64 `json:"degrees_of_freedom"`
MedianBeforeAnomaly float64 `json:"median_before_anomaly"`
MedianAfterAnomaly float64 `json:"median_after_anomaly"`
PValue float64 `json:"p_value"`
SegmentSizeAfter int `json:"segment_size_after"`
SegmentSizeBefore int `json:"segment_size_before"`
StdDevBeforeAnomaly float64 `json:"std_dev_before_anomaly"`
TStatistics float64 `json:"t_statistic"`
SubscriptionName string `json:"subscription_name"`
BugComponent string `json:"bug_component"`
BugLabels []string `json:"bug_labels"`
BugCcEmails []string `json:"bug_cc_emails"`
BisectIDs []string `json:"bisect_ids"`
Timestamp string `json:"timestamp,omitempty"`
}
// For testing purpose only.
type getAnomaliesResponse struct {
Anomalies map[string][]anomaly `json:"anomalies"`
}
// For testing purpose only.
type getAnomaliesRequest struct {
Tests []string `json:"tests,omitempty"`
MaxRevision string `json:"max_revision,omitempty"`
MinRevision string `json:"min_revision,omitempty"`
Revision int `json:"revision,omitempty"`
NeedAggregation bool `json:"need_aggregation,omitempty"`
}
type marshalErrorType struct{}
func (m *marshalErrorType) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("simulated marshal error")
}
func TestSendPostRequest(t *testing.T) {
ctx := context.Background()
anomaly1 := anomaly{
Id: "101",
TestPath: "Master>Builder>Test>Subtest.Value",
BugId: 12345,
StartRevision: 10001,
EndRevision: 10005,
StartRevisionHash: "abcdef123456",
EndRevisionHash: "fedcba654321",
IsImprovement: false,
Recovered: true,
State: "Closed",
Statistics: "mean",
Unit: "ms",
DegreeOfFreedom: 10.5,
MedianBeforeAnomaly: 150.2,
MedianAfterAnomaly: 185.7,
PValue: 0.001,
SegmentSizeAfter: 50,
SegmentSizeBefore: 48,
StdDevBeforeAnomaly: 12.3,
TStatistics: -3.45,
SubscriptionName: "My Test Subscription",
BugComponent: "Blink>Performance",
BugLabels: []string{"Perf-Regression", "Mobile"},
BugCcEmails: []string{"user1@example.com", "user2@example.com"},
BisectIDs: []string{"bisect_run_001", "bisect_run_002"},
}
anomaly2 := anomaly{
Id: "102",
TestPath: "Master>Builder>Test>AnotherSubtest.Score",
BugId: 0,
StartRevision: 10010,
EndRevision: 10012,
IsImprovement: true,
Recovered: false,
State: "Investigating",
Statistics: "percentile_90",
Unit: "score",
DegreeOfFreedom: 8.0,
MedianBeforeAnomaly: 800.0,
MedianAfterAnomaly: 750.5,
PValue: 0.045,
SegmentSizeAfter: 30,
SegmentSizeBefore: 35,
StdDevBeforeAnomaly: 25.0,
TStatistics: 2.15,
SubscriptionName: "Performance Score Alerts",
BugComponent: "",
BugLabels: []string{"Perf-Improvement"},
BugCcEmails: []string{},
BisectIDs: []string{},
}
tests := []struct {
name string
apiName string
functionName string
requestObj any
responseObjTemplate any
acceptedStatusCodes []int
mockServerHandler http.HandlerFunc
directCallLegacy bool // For URL generation variance
expectError bool
expectedErrorMsg string // Substring to check in the error
expectedResponse any // Expected state of responseObj after call
checkRequestBody func(t *testing.T, bodyBytes []byte) // Optional: to validate the sent body
}{
{
name: "Successful request",
apiName: "anomalies",
functionName: "find",
requestObj: getAnomaliesRequest{
Tests: []string{"foo/bar", "foo/baz"},
MaxRevision: strconv.Itoa(987654321),
MinRevision: strconv.Itoa(123456789),
},
responseObjTemplate: &getAnomaliesResponse{},
acceptedStatusCodes: []int{http.StatusOK, http.StatusCreated},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.Equal(t, "/anomalies/find", r.URL.Path) // Check generated URL path
bodyBytes, err := io.ReadAll(r.Body)
assert.NoError(t, err)
var receivedReq getAnomaliesRequest
assert.NoError(t, json.Unmarshal(bodyBytes, &receivedReq))
assert.Equal(t, getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"}, receivedReq)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}})
if err != nil {
assert.Fail(t, "the Encode should always pass.")
}
},
expectError: false,
expectedResponse: &getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}},
},
{
name: "Request marshalling error",
apiName: "data",
functionName: "upload",
requestObj: &marshalErrorType{}, // This type will cause json.Marshal to fail
responseObjTemplate: &getAnomaliesResponse{},
acceptedStatusCodes: []int{http.StatusOK},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
t.Fatal("Server should not be called if marshalling fails")
},
expectError: true,
expectedErrorMsg: "json: error calling MarshalJSON for type *chromeperf.marshalErrorType: simulated marshal error",
},
{
name: "HTTP Post error (server down)",
apiName: "data",
functionName: "upload",
requestObj: getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"},
responseObjTemplate: &getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}},
acceptedStatusCodes: []int{http.StatusOK},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
// Server handler won't be called as we close the server immediately
},
expectError: true,
expectedErrorMsg: "connect: connection refused", // Error from httputils.PostWithContext
},
{
name: "Unexpected status code",
apiName: "data",
functionName: "upload",
requestObj: getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"},
responseObjTemplate: &getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}},
acceptedStatusCodes: []int{http.StatusOK},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := fmt.Fprint(w, "Internal Error Detail")
if err != nil {
assert.Fail(t, "the fmt.Fprint should always pass.")
}
},
expectError: true,
expectedErrorMsg: "Receive status 500 from chromeperf",
},
{
name: "Response unmarshalling error (malformed JSON)",
apiName: "data",
functionName: "upload",
requestObj: getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"},
responseObjTemplate: &getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}},
acceptedStatusCodes: []int{http.StatusOK},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprint(w, `{"status":"success", "processed_value": "not_an_int"`) // Malformed
if err != nil {
assert.Fail(t, "the fmt.Fprint should always pass.")
}
},
expectError: true,
expectedErrorMsg: "unexpected EOF",
},
{
name: "Response with nil response body (responseObj is nil)",
apiName: "action",
functionName: "trigger",
requestObj: getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"},
responseObjTemplate: nil, // Indicate no response body to be decoded
acceptedStatusCodes: []int{http.StatusNoContent},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
},
expectError: true,
expectedErrorMsg: "EOF",
},
{
name: "Context timeout before server responds",
apiName: "data",
functionName: "upload_timeout",
requestObj: getAnomaliesRequest{Tests: []string{"foo/bar", "foo/baz"}, MaxRevision: "987654321", MinRevision: "123456789"},
responseObjTemplate: &getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}},
acceptedStatusCodes: []int{http.StatusOK},
mockServerHandler: func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond) // Server takes longer than client timeout
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(&getAnomaliesResponse{Anomalies: map[string][]anomaly{
"foo/bar": {anomaly1},
"foo/baz": {anomaly1, anomaly2},
}})
if err != nil {
assert.Fail(t, "the Encode should always pass.")
}
},
expectError: true,
expectedErrorMsg: "context deadline exceeded", // Error from client.Do due to context
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.mockServerHandler))
// Special handling for the "server down" test
if tt.name == "HTTP Post error (server down)" {
server.Close() // Close immediately to simulate server down
} else {
defer server.Close()
}
// Due to the chromeperfClient.go:118, create urlOverride with path.
apiURL := fmt.Sprintf("%s/%s/%s", server.URL, tt.apiName, tt.functionName)
client := NewChromePerfTestClient(server.Client(), apiURL, false)
// For context timeout test
currentCtx := ctx
var cancel context.CancelFunc
if tt.name == "Context timeout before server responds" {
currentCtx, cancel = context.WithTimeout(ctx, 50*time.Millisecond) // Client timeout shorter than server sleep
defer cancel()
}
// Make a new instance of the response object for each test run
var actualResponseObj interface{}
if sr, ok := tt.responseObjTemplate.(*getAnomaliesResponse); ok && sr != nil {
actualResponseObj = &getAnomaliesResponse{} // new pointer
}
err := client.SendPostRequest(currentCtx, tt.apiName, tt.functionName, tt.requestObj, actualResponseObj, tt.acceptedStatusCodes)
if tt.expectError {
require.Error(t, err)
if tt.expectedErrorMsg != "" {
assert.Contains(t, skerr.Unwrap(err).Error(), tt.expectedErrorMsg, "Error message mismatch")
}
} else {
require.NoError(t, err)
if tt.expectedResponse != nil {
assert.Equal(t, tt.expectedResponse, actualResponseObj, "Response object mismatch")
} else {
assert.Nil(t, actualResponseObj, "Expected responseObj to remain nil")
}
}
})
}
}