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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/js/modules/k6/browser/browser/page_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions internal/js/modules/k6/browser/browser/route_mapping.go
Original file line number Diff line number Diff line change
@@ -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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at https://playwright.dev/docs/api/class-route#route-fulfill - you are not handling path, json and response . Can we have a comment here. Also I feel like handling all but response is likely fairly quick 🤔 .

But if not maybe lets throw an exception instead of silently doing the wrong thing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be ok to wait for feedback for response and json.

Instead of working with path we should probably encourage users to work with the current idiomatic k6 way of reading a file and then passing it the bytes to body.

So, yeah, happy with throwing an error if a user tries to work with those fields that aren't implemented.

Copy link
Contributor Author

@AgnesToulet AgnesToulet Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 67f24be

I agree that json should be quick to implement and used by a lot of users but wanted to keep the scope of the PR as small as possible. We can definitely iterate on it in future releases.

}

return fopts, nil
}
6 changes: 5 additions & 1 deletion internal/js/modules/k6/browser/common/frame_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 26 additions & 9 deletions internal/js/modules/k6/browser/common/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand Down
83 changes: 63 additions & 20 deletions internal/js/modules/k6/browser/common/network_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package common

import (
"context"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -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)
}
}
}()

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -895,32 +901,69 @@ 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 != "" {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering here... Is it better to keep it like this or actually remove this field from the FulfillOptions struct and directly add it to the Headers option field in parseFulfillOptions method, WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's better to be as close to Playwright as possible in UX terms.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I agree, this would only be "under the hood". The API spec would stay the same but we wouldn't bother with this field in the other layers of the codebase.

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.
// Most probably this happens when trying to fail a site's background request
// 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.
Expand Down
32 changes: 27 additions & 5 deletions internal/js/modules/k6/browser/tests/frame_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
Loading