diff --git a/internal/js/modules/k6/browser/browser/page_mapping.go b/internal/js/modules/k6/browser/browser/page_mapping.go index 785e9b5ada6..869d157e1f8 100644 --- a/internal/js/modules/k6/browser/browser/page_mapping.go +++ b/internal/js/modules/k6/browser/browser/page_mapping.go @@ -909,9 +909,10 @@ func mapPageRoute(vu moduleVU, p *common.Page) func(path sobek.Value, handler so tq.Queue(func() error { defer close(done) + mr := mapRoute(vu, route) _, err = handler( sobek.Undefined(), - vu.Runtime().ToValue(route), + vu.Runtime().ToValue(mr), ) if err != nil { rtnErr = fmt.Errorf("executing page.route('%s') handler: %w", path, err) diff --git a/internal/js/modules/k6/browser/browser/route_mapping.go b/internal/js/modules/k6/browser/browser/route_mapping.go new file mode 100644 index 00000000000..442f4c145d8 --- /dev/null +++ b/internal/js/modules/k6/browser/browser/route_mapping.go @@ -0,0 +1,74 @@ +package browser + +import ( + "context" + "fmt" + + "github.com/grafana/sobek" + + "go.k6.io/k6/internal/js/modules/k6/browser/common" + "go.k6.io/k6/internal/js/modules/k6/browser/k6ext" + jsCommon "go.k6.io/k6/js/common" +) + +// mapRoute to the JS module. +func mapRoute(vu moduleVU, route *common.Route) mapping { + return mapping{ + "abort": func(reason string) *sobek.Promise { + return k6ext.Promise(vu.Context(), func() (any, error) { + return nil, route.Abort(reason) + }) + }, + "fulfill": func(opts sobek.Value) *sobek.Promise { + fopts, err := parseFulfillOptions(vu.Context(), opts) + return k6ext.Promise(vu.Context(), func() (any, error) { + if err != nil { + return nil, err + } + return nil, route.Fulfill(fopts) + }) + }, + "request": func() mapping { + return mapRequest(vu, route.Request()) + }, + } +} + +func parseFulfillOptions(ctx context.Context, opts sobek.Value) (common.FulfillOptions, error) { + fopts := common.FulfillOptions{} + if !sobekValueExists(opts) { + return fopts, nil + } + + rt := k6ext.Runtime(ctx) + obj := opts.ToObject(rt) + for _, k := range obj.Keys() { + switch k { + case "body": + bytesBody, err := jsCommon.ToBytes(obj.Get(k).Export()) + if err != nil { + return fopts, err + } + fopts.Body = bytesBody + case "contentType": + fopts.ContentType = obj.Get(k).String() + case "headers": + headers := obj.Get(k).ToObject(rt) + headersKeys := headers.Keys() + fopts.Headers = make([]common.HTTPHeader, len(headersKeys)) + for i, hk := range headersKeys { + fopts.Headers[i] = common.HTTPHeader{ + Name: hk, + Value: headers.Get(hk).String(), + } + } + case "status": + fopts.Status = obj.Get(k).ToInteger() + // As we don't support all fields that PW supports, we return an error to inform the user + default: + return fopts, fmt.Errorf("unsupported fulfill option: '%s'", k) + } + } + + return fopts, nil +} diff --git a/internal/js/modules/k6/browser/common/frame_manager.go b/internal/js/modules/k6/browser/common/frame_manager.go index 87d40953983..8af9b1850b0 100644 --- a/internal/js/modules/k6/browser/common/frame_manager.go +++ b/internal/js/modules/k6/browser/common/frame_manager.go @@ -574,7 +574,11 @@ func (m *FrameManager) requestStarted(req *Request) { return } - route.Continue() + + if err := route.Continue(); err != nil { + m.logger.Errorf("FrameManager:requestStarted", + "fmid:%d rurl:%s error continuing request: %v", m.ID(), req.URL(), err) + } } // Frames returns a list of frames on the page. diff --git a/internal/js/modules/k6/browser/common/http.go b/internal/js/modules/k6/browser/common/http.go index 4c3758b2e91..1198dee7503 100644 --- a/internal/js/modules/k6/browser/common/http.go +++ b/internal/js/modules/k6/browser/common/http.go @@ -718,6 +718,14 @@ type Route struct { handled bool } +// FulfillOptions are response fields that can be set when fulfilling a request. +type FulfillOptions struct { + Body []byte + ContentType string + Headers []HTTPHeader + Status int64 +} + // NewRoute creates a new Route that allows to modify a request's behavior. func NewRoute(logger *log.Logger, networkManager *NetworkManager, request *Request) *Route { return &Route{ @@ -728,37 +736,46 @@ func NewRoute(logger *log.Logger, networkManager *NetworkManager, request *Reque } } +// Request returns the request associated with the route. func (r *Route) Request() *Request { return r.request } // Abort aborts the request with the given error code. -func (r *Route) Abort(errorCode string) { +func (r *Route) Abort(errorCode string) error { err := r.startHandling() if err != nil { - r.logger.Errorf("Route:Abort", "rurl:%s err:%s", r.request.URL(), err) - return + return err } if errorCode == "" { errorCode = "failed" } - r.networkManager.AbortRequest(r.request.interceptionID, errorCode) + return r.networkManager.AbortRequest(r.request.interceptionID, errorCode) } // Continue continues the request. -func (r *Route) Continue() { +func (r *Route) Continue() error { + err := r.startHandling() + if err != nil { + return err + } + + return r.networkManager.ContinueRequest(r.request.interceptionID) +} + +// Fulfill fulfills the request with the given options for the response. +func (r *Route) Fulfill(opts FulfillOptions) error { err := r.startHandling() if err != nil { - r.logger.Errorf("Route:Continue", "rurl:%s err:%s", r.request.URL(), err) - return + return err } - r.networkManager.ContinueRequest(r.request.interceptionID) + return r.networkManager.FulfillRequest(r.request, opts) } func (r *Route) startHandling() error { if r.handled { - return fmt.Errorf("route %s is already handled", r.request.URL()) + return fmt.Errorf("route is already handled") } r.handled = true return nil diff --git a/internal/js/modules/k6/browser/common/network_manager.go b/internal/js/modules/k6/browser/common/network_manager.go index 66694f2a3b2..b7a782b7228 100644 --- a/internal/js/modules/k6/browser/common/network_manager.go +++ b/internal/js/modules/k6/browser/common/network_manager.go @@ -2,9 +2,11 @@ package common import ( "context" + "encoding/base64" "errors" "fmt" "net" + "net/http" "net/url" "strconv" "strings" @@ -642,7 +644,11 @@ func (m *NetworkManager) onRequestPaused(event *fetch.EventRequestPaused) { // If no route was added, continue all requests if m.frameManager.page == nil || !m.frameManager.page.hasRoutes() { - m.ContinueRequest(event.RequestID) + err := m.ContinueRequest(event.RequestID) + if err != nil { + m.logger.Errorf("NetworkManager:onRequestPaused", + "continuing request %s %s: %s", event.Request.Method, event.Request.URL, err) + } } }() @@ -852,13 +858,12 @@ func (m *NetworkManager) Authenticate(credentials Credentials) error { return nil } -func (m *NetworkManager) AbortRequest(requestID fetch.RequestID, errorReason string) { +func (m *NetworkManager) AbortRequest(requestID fetch.RequestID, errorReason string) error { m.logger.Debugf("NetworkManager:AbortRequest", "aborting request (id: %s, errorReason: %s)", requestID, errorReason) netErrorReason, ok := m.errorReasons[errorReason] if !ok { - m.logger.Errorf("NetworkManager:AbortRequest", "unknown error code: %s", errorReason) - return + return fmt.Errorf("unknown error code: %s", errorReason) } action := fetch.FailRequest(requestID, netErrorReason) @@ -869,13 +874,14 @@ func (m *NetworkManager) AbortRequest(requestID fetch.RequestID, errorReason str if errors.Is(err, context.Canceled) { m.logger.Debug("NetworkManager:AbortRequest", "context canceled interrupting request") } else { - m.logger.Errorf("NetworkManager:AbortRequest", "fail to abort request (id: %s): %s", requestID, err) + return fmt.Errorf("fail to abort request (id: %s): %w", requestID, err) } - return } + + return nil } -func (m *NetworkManager) ContinueRequest(requestID fetch.RequestID) { +func (m *NetworkManager) ContinueRequest(requestID fetch.RequestID) error { m.logger.Debugf("NetworkManager:ContinueRequest", "continuing request (id: %s)", requestID) action := fetch.ContinueRequest(requestID) @@ -885,7 +891,7 @@ func (m *NetworkManager) ContinueRequest(requestID fetch.RequestID) { // while the iteration is ending and therefore the browser context is being closed. if errors.Is(err, context.Canceled) { m.logger.Debug("NetworkManager:ContinueRequest", "context canceled continuing request") - return + return nil } // This error message is an internal issue, rather than something that the user can @@ -895,19 +901,39 @@ func (m *NetworkManager) ContinueRequest(requestID fetch.RequestID) { if strings.Contains(err.Error(), "Invalid InterceptionId") { m.logger.Debugf("NetworkManager:ContinueRequest", "invalid interception ID (%s) continuing request: %s", requestID, err) - return + return nil } - m.logger.Errorf("NetworkManager:ContinueRequest", "fail to continue request (id: %s): %s", - requestID, err) + return fmt.Errorf("fail to continue request (id: %s): %w", requestID, err) } + + return nil } -func (m *NetworkManager) FulfillRequest(requestID fetch.RequestID, params fetch.FulfillRequestParams) { - action := fetch.FulfillRequest(requestID, params.ResponseCode). - WithResponseHeaders(params.ResponseHeaders). - WithResponsePhrase(params.ResponsePhrase). - WithBody(params.Body) +func (m *NetworkManager) FulfillRequest(request *Request, opts FulfillOptions) error { + responseCode := int64(http.StatusOK) + if opts.Status != 0 { + responseCode = opts.Status + } + + action := fetch.FulfillRequest(request.interceptionID, responseCode) + + if opts.ContentType != "" { + opts.Headers = append(opts.Headers, HTTPHeader{ + Name: "Content-Type", + Value: opts.ContentType, + }) + } + + headers := toFetchHeaders(opts.Headers) + if len(headers) > 0 { + action = action.WithResponseHeaders(headers) + } + + if len(opts.Body) > 0 { + b64Body := base64.StdEncoding.EncodeToString(opts.Body) + action = action.WithBody(b64Body) + } if err := action.Do(cdp.WithExecutor(m.ctx, m.session)); err != nil { // Avoid logging as error when context is canceled. @@ -915,12 +941,29 @@ func (m *NetworkManager) FulfillRequest(requestID fetch.RequestID, params fetch. // while the iteration is ending and therefore the browser context is being closed. if errors.Is(err, context.Canceled) { m.logger.Debug("NetworkManager:FulfillRequest", "context canceled fulfilling request") - } else { - m.logger.Errorf("NetworkManager:FulfillRequest", "fail to fulfill request (id: %s): %s", - requestID, err) + return nil + } + + return fmt.Errorf("fail to fulfill request (id: %s): %w", + request.interceptionID, err) + } + + return nil +} + +func toFetchHeaders(headers []HTTPHeader) []*fetch.HeaderEntry { + if len(headers) == 0 { + return nil + } + + fetchHeaders := make([]*fetch.HeaderEntry, len(headers)) + for i, header := range headers { + fetchHeaders[i] = &fetch.HeaderEntry{ + Name: header.Name, + Value: header.Value, } - return } + return fetchHeaders } // SetExtraHTTPHeaders sets extra HTTP request headers to be sent with every request. diff --git a/internal/js/modules/k6/browser/tests/frame_manager_test.go b/internal/js/modules/k6/browser/tests/frame_manager_test.go index 3d63b7176c7..3b2417ca1b5 100644 --- a/internal/js/modules/k6/browser/tests/frame_manager_test.go +++ b/internal/js/modules/k6/browser/tests/frame_manager_test.go @@ -148,7 +148,8 @@ func TestFrameManagerRequestStartedWithRoutes(t *testing.T) { name: "continue_request_with_matching_string_route", routePath: "/data/first", routeHandler: func(route *common.Route) { - route.Continue() + err := route.Continue() + assert.NoError(t, err) }, routeHandlerCallsCount: 1, apiHandlerCallsCount: 2, @@ -157,7 +158,8 @@ func TestFrameManagerRequestStartedWithRoutes(t *testing.T) { name: "continue_request_with_non_matching_string_route", routePath: "/data/third", routeHandler: func(route *common.Route) { - route.Continue() + err := route.Continue() + assert.NoError(t, err) }, routeHandlerCallsCount: 0, apiHandlerCallsCount: 2, @@ -166,7 +168,8 @@ func TestFrameManagerRequestStartedWithRoutes(t *testing.T) { name: "continue_request_with_multiple_matching_regex_route", routePath: "/data/.*", routeHandler: func(route *common.Route) { - route.Continue() + err := route.Continue() + assert.NoError(t, err) }, routeHandlerCallsCount: 2, apiHandlerCallsCount: 2, @@ -175,7 +178,8 @@ func TestFrameManagerRequestStartedWithRoutes(t *testing.T) { name: "abort_first_request", routePath: "/data/first", routeHandler: func(route *common.Route) { - route.Abort("failed") + err := route.Abort("failed") + assert.NoError(t, err) }, routeHandlerCallsCount: 1, apiHandlerCallsCount: 0, // Second API call is not made because the first throws an error @@ -184,7 +188,25 @@ func TestFrameManagerRequestStartedWithRoutes(t *testing.T) { name: "abort_second_request", routePath: "/data/second", routeHandler: func(route *common.Route) { - route.Abort("failed") + err := route.Abort("failed") + assert.NoError(t, err) + }, + routeHandlerCallsCount: 1, + apiHandlerCallsCount: 1, + }, + { + name: "fulfill_request", + routePath: "/data/first", + routeHandler: func(route *common.Route) { + err := route.Fulfill(common.FulfillOptions{ + Body: []byte(`{"data": "Fulfilled data"}`), + ContentType: "application/json", + Headers: []common.HTTPHeader{ + {Name: "Access-Control-Allow-Origin", Value: "*"}, + }, + Status: 200, + }) + assert.NoError(t, err) }, routeHandlerCallsCount: 1, apiHandlerCallsCount: 1,