Skip to content
This repository was archived by the owner on Jan 30, 2025. It is now read-only.
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
19 changes: 19 additions & 0 deletions api/console_message.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

// ConsoleMessage represents a page console message.
type ConsoleMessage struct {
// Args represent the list of arguments passed to a console function call.
Args []JSHandle

// Page is the page that produced the console message, if any.
Page Page

// Text represents the text of the console message.
Text string

// Type is the type of the console message.
// It can be one of 'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml',
// 'table', 'trace', 'clear', 'startGroup', 'startGroupCollapsed', 'endGroup',
// 'assert', 'profile', 'profileEnd', 'count', 'timeEnd'.
Type string
}
1 change: 1 addition & 0 deletions api/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Page interface {
// Locator creates and returns a new locator for this page (main frame).
Locator(selector string, opts goja.Value) Locator
MainFrame() Frame
On(event string, handler func(*ConsoleMessage) error) error
Opener() Page
Pause()
Pdf(opts goja.Value) []byte
Expand Down
41 changes: 40 additions & 1 deletion browser/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,15 @@ func mapPage(vu moduleVU, p api.Page) mapping {
mf := mapFrame(vu, p.MainFrame())
return rt.ToValue(mf).ToObject(rt)
},
"mouse": rt.ToValue(p.GetMouse()).ToObject(rt),
"mouse": rt.ToValue(p.GetMouse()).ToObject(rt),
"on": func(event string, handler goja.Callable) error {
mapMsgAndHandleEvent := func(m *api.ConsoleMessage) error {
mapping := mapConsoleMessage(vu, m)
_, err := handler(goja.Undefined(), vu.Runtime().ToValue(mapping))
return err
}
return p.On(event, mapMsgAndHandleEvent) //nolint:wrapcheck
},
"opener": p.Opener,
"pause": p.Pause,
"pdf": p.Pdf,
Expand Down Expand Up @@ -666,6 +674,37 @@ func mapBrowserContext(vu moduleVU, bc api.BrowserContext) mapping {
}
}

// mapConsoleMessage to the JS module.
func mapConsoleMessage(vu moduleVU, cm *api.ConsoleMessage) mapping {
rt := vu.Runtime()
return mapping{
"args": func() *goja.Object {
var (
margs []mapping
args = cm.Args
)
for _, arg := range args {
a := mapJSHandle(vu, arg)
margs = append(margs, a)
}

return rt.ToValue(margs).ToObject(rt)
},
// page(), text() and type() are defined as
// functions in order to match Playwright's API
"page": func() *goja.Object {
mp := mapPage(vu, cm.Page)
return rt.ToValue(mp).ToObject(rt)
},
"text": func() *goja.Object {
return rt.ToValue(cm.Text).ToObject(rt)
},
"type": func() *goja.Object {
return rt.ToValue(cm.Type).ToObject(rt)
},
}
}

// mapBrowser to the JS module.
func mapBrowser(vu moduleVU) mapping {
rt := vu.Runtime()
Expand Down
11 changes: 11 additions & 0 deletions browser/mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ func TestMappings(t *testing.T) {
return mapLocator(moduleVU{VU: vu}, &common.Locator{})
},
},
"mapConsoleMessage": {
apiInterface: (*interface {
Args() []api.JSHandle
Page() api.Page
Text() string
Type() string
})(nil),
mapp: func() mapping {
return mapConsoleMessage(moduleVU{VU: vu}, &api.ConsoleMessage{})
},
},
} {
tt := tt
t.Run(name, func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion common/frame_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,7 @@ func (fs *FrameSession) updateViewport() error {
return nil
}

func (fs *FrameSession) executionContextForID( //nolint:unused
func (fs *FrameSession) executionContextForID(
executionContextID cdpruntime.ExecutionContextID,
) (*ExecutionContext, error) {
fs.contextIDToContextMu.Lock()
Expand Down
194 changes: 186 additions & 8 deletions common/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@ import (
"sync"
"time"

"github.com/grafana/xk6-browser/api"
"github.com/grafana/xk6-browser/k6ext"
"github.com/grafana/xk6-browser/log"

k6modules "go.k6.io/k6/js/modules"

"github.com/chromedp/cdproto"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/emulation"
Expand All @@ -23,16 +18,29 @@ import (
cdpruntime "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/target"
"github.com/dop251/goja"
"github.com/mstoykov/k6-taskqueue-lib/taskqueue"

"github.com/grafana/xk6-browser/api"
"github.com/grafana/xk6-browser/k6ext"
"github.com/grafana/xk6-browser/log"

k6modules "go.k6.io/k6/js/modules"
)

const webVitalBinding = "k6browserSendWebVitalMetric"
const (
webVitalBinding = "k6browserSendWebVitalMetric"

eventPageConsoleAPICalled = "console"
)

// Ensure page implements the EventEmitter, Target and Page interfaces.
var (
_ EventEmitter = &Page{}
_ api.Page = &Page{}
)

type consoleEventHandlerFunc func(*api.ConsoleMessage) error

// Page stores Page/tab related context.
type Page struct {
BaseEventEmitter
Expand Down Expand Up @@ -70,13 +78,19 @@ type Page struct {

backgroundPage bool

eventCh chan Event
eventHandlers map[string][]consoleEventHandlerFunc
eventHandlersMu sync.RWMutex

mainFrameSession *FrameSession
frameSessions map[cdp.FrameID]*FrameSession
frameSessionsMu sync.RWMutex
workers map[target.SessionID]*Worker
routes []api.Route
vu k6modules.VU

tq *taskqueue.TaskQueue

logger *log.Logger
}

Expand Down Expand Up @@ -105,6 +119,8 @@ func NewPage(
timeoutSettings: NewTimeoutSettings(bctx.timeoutSettings),
Keyboard: NewKeyboard(ctx, s),
jsEnabled: true,
eventCh: make(chan Event),
eventHandlers: make(map[string][]consoleEventHandlerFunc),
frameSessions: make(map[cdp.FrameID]*FrameSession),
workers: make(map[target.SessionID]*Worker),
routes: make([]api.Route, 0),
Expand Down Expand Up @@ -136,6 +152,8 @@ func NewPage(
p.Mouse = NewMouse(ctx, s, p.frameManager.MainFrame(), bctx.timeoutSettings, p.Keyboard)
p.Touchscreen = NewTouchscreen(ctx, s, p.Keyboard)

p.initEvents()

action := target.SetAutoAttach(true, true).WithFlatten(true)
if err := action.Do(cdp.WithExecutor(p.ctx, p.session)); err != nil {
return nil, fmt.Errorf("internal error while auto attaching to browser pages: %w", err)
Expand All @@ -153,6 +171,48 @@ func NewPage(
return &p, nil
}

func (p *Page) initEvents() {
p.logger.Debugf("Page:initEvents",
"sid:%v tid:%v", p.session.ID(), p.targetID)

events := []string{
cdproto.EventRuntimeConsoleAPICalled,
}
p.session.on(p.ctx, events, p.eventCh)

go func() {
p.logger.Debugf("Page:initEvents:go",
"sid:%v tid:%v", p.session.ID(), p.targetID)
defer func() {
p.logger.Debugf("Page:initEvents:go:return",
"sid:%v tid:%v", p.session.ID(), p.targetID)
// TaskQueue is only initialized when calling page.on() method
// so users are not always required to close the page in order
// to let the iteration finish.
if p.tq != nil {
p.tq.Close()
}
}()

for {
select {
case <-p.session.Done():
p.logger.Debugf("Page:initEvents:go:session.done",
"sid:%v tid:%v", p.session.ID(), p.targetID)
return
case <-p.ctx.Done():
p.logger.Debugf("Page:initEvents:go:ctx.Done",
"sid:%v tid:%v", p.session.ID(), p.targetID)
return
case event := <-p.eventCh:
if ev, ok := event.data.(*cdpruntime.EventConsoleAPICalled); ok {
p.onConsoleAPICalled(ev)
}
}
}
}()
}

func (p *Page) closeWorker(sessionID target.SessionID) {
p.logger.Debugf("Page:closeWorker", "sid:%v", sessionID)

Expand Down Expand Up @@ -734,6 +794,32 @@ func (p *Page) MainFrame() api.Frame {
return mf
}

// On subscribes to a page event for which the given handler will be executed
// passing in the ConsoleMessage associated with the event.
// The only accepted event value is 'console'.
func (p *Page) On(event string, handler func(*api.ConsoleMessage) error) error {
if event != eventPageConsoleAPICalled {
return fmt.Errorf("unknown page event: %q, must be %q", event, eventPageConsoleAPICalled)
}

// Once the TaskQueue is initialized, it has to be closed so the event loop can finish.
// Therefore, instead of doing it in the constructor, we initialize it only when page.on()
// is called, so the user is only required to close the page it using this method.
if p.tq == nil {
p.tq = taskqueue.New(p.vu.RegisterCallback)
}

p.eventHandlersMu.Lock()
defer p.eventHandlersMu.Unlock()

if _, ok := p.eventHandlers[eventPageConsoleAPICalled]; !ok {
p.eventHandlers[eventPageConsoleAPICalled] = make([]consoleEventHandlerFunc, 0, 1)
}
p.eventHandlers[eventPageConsoleAPICalled] = append(p.eventHandlers[eventPageConsoleAPICalled], handler)

return nil
}

// Opener returns the opener of the target.
func (p *Page) Opener() api.Page {
return p.opener
Expand Down Expand Up @@ -1039,8 +1125,73 @@ func (p *Page) Workers() []api.Worker {
return workers
}

func (p *Page) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) {
// If there are no handlers for EventConsoleAPICalled, return
p.eventHandlersMu.RLock()
if _, ok := p.eventHandlers[eventPageConsoleAPICalled]; !ok {
p.eventHandlersMu.RUnlock()
return
}
p.eventHandlersMu.RUnlock()

m, err := p.consoleMsgFromConsoleEvent(event)
if err != nil {
p.logger.Errorf("Page:onConsoleAPICalled", "building console message: %v", err)
return
}

p.eventHandlersMu.RLock()
defer p.eventHandlersMu.RUnlock()
for _, h := range p.eventHandlers[eventPageConsoleAPICalled] {
h := h
// Use TaskQueue in order to synchronize handlers execution in the event loop,
// as it is not thread safe and events are processed from a background goroutine
p.tq.Queue(func() error {
if err := h(m); err != nil {
return fmt.Errorf("executing onConsoleAPICalled handler: %w", err)
}
return nil
})
}
}

func (p *Page) consoleMsgFromConsoleEvent(e *cdpruntime.EventConsoleAPICalled) (*api.ConsoleMessage, error) {
execCtx, err := p.executionContextForID(e.ExecutionContextID)
if err != nil {
return nil, err
}

var (
l = p.logger.WithTime(e.Timestamp.Time()).
WithField("source", "browser").
WithField("browser_source", "console-api")

objects = make([]any, 0, len(e.Args))
objectHandles = make([]api.JSHandle, 0, len(e.Args))
)

for _, robj := range e.Args {
i, err := parseRemoteObject(robj)
if err != nil {
handleParseRemoteObjectErr(p.ctx, err, l)
}

objects = append(objects, i)
objectHandles = append(objectHandles, NewJSHandle(
p.ctx, p.session, execCtx, execCtx.Frame(), robj, p.logger,
))
}

return &api.ConsoleMessage{
Args: objectHandles,
Page: p,
Text: textForConsoleEvent(e, objects),
Type: e.Type.String(),
}, nil
}

// executionContextForID returns the page ExecutionContext for the given ID.
func (p *Page) executionContextForID( //nolint:unused
func (p *Page) executionContextForID(
executionContextID cdpruntime.ExecutionContextID,
) (*ExecutionContext, error) {
p.frameSessionsMu.RLock()
Expand All @@ -1063,3 +1214,30 @@ func (p *Page) sessionID() (sid target.SessionID) {
}
return sid
}

// textForConsoleEvent generates the text representation for a consoleAPICalled event
// mimicking Playwright's behavior.
func textForConsoleEvent(e *cdpruntime.EventConsoleAPICalled, args []any) string {
if e.Type.String() == "dir" || e.Type.String() == "dirxml" ||
e.Type.String() == "table" {
if len(e.Args) > 0 {
// These commands accept a single arg
return e.Args[0].Description
}
return ""
}

// args is a mix of string and non strings, so using fmt.Sprint(args...)
// might not add spaces between all elements, therefore use a strings.Builder
// and handle format and concatenation
var b strings.Builder
for i, a := range args {
format := " %v"
if i == 0 {
format = "%v"
}
b.WriteString(fmt.Sprintf(format, a))
}

return b.String()
}
Loading