diff --git a/cmd/tests/cmd_run_test.go b/cmd/tests/cmd_run_test.go index 0df7021af5d..459afcfd3b8 100644 --- a/cmd/tests/cmd_run_test.go +++ b/cmd/tests/cmd_run_test.go @@ -1827,80 +1827,6 @@ func BenchmarkReadResponseBody(b *testing.B) { cmd.ExecuteWithGlobalState(ts.GlobalState) } -func TestBrowserPermissions(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - envVarValue string - envVarMsgValue string - expectedExitCode exitcodes.ExitCode - expectedError string - }{ - { - name: "no env var set", - envVarValue: "", - expectedExitCode: 107, - expectedError: "To run browser tests set env var K6_BROWSER_ENABLED=true", - }, - { - name: "env var set but set to false", - envVarValue: "false", - expectedExitCode: 107, - expectedError: "To run browser tests set env var K6_BROWSER_ENABLED=true", - }, - { - name: "env var set but set to 09adsu", - envVarValue: "09adsu", - expectedExitCode: 107, - expectedError: "To run browser tests set env var K6_BROWSER_ENABLED=true", - }, - { - name: "with custom message", - envVarValue: "09adsu", - envVarMsgValue: "Try again later", - expectedExitCode: 107, - expectedError: "Try again later", - }, - { - name: "env var set and set to true", - envVarValue: "true", - expectedExitCode: 0, - expectedError: "", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - script := ` - import { chromium } from 'k6/experimental/browser'; - - export default function() {}; - ` - - ts := getSingleFileTestState(t, script, []string{}, tt.expectedExitCode) - if tt.envVarValue != "" { - ts.Env["K6_BROWSER_ENABLED"] = tt.envVarValue - } - if tt.envVarMsgValue != "" { - ts.Env["K6_BROWSER_ENABLED_MSG"] = tt.envVarMsgValue - } - cmd.ExecuteWithGlobalState(ts.GlobalState) - - loglines := ts.LoggerHook.Drain() - - if tt.expectedError == "" { - require.Len(t, loglines, 0) - return - } - - assert.Contains(t, loglines[0].Message, tt.expectedError) - }) - } -} - func TestUIRenderOutput(t *testing.T) { t.Parallel() @@ -2228,3 +2154,65 @@ func BenchmarkRunEvents(b *testing.B) { } } } + +func TestBrowserPermissions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options string + expectedExitCode exitcodes.ExitCode + expectedError string + }{ + { + name: "browser option not set", + options: "", + expectedExitCode: 0, + expectedError: "GoError: browser not found in registry. make sure to set browser type option in scenario definition in order to use the browser module", + }, + { + name: "browser option set", + options: `export const options = { + scenarios: { + browser: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + }`, + expectedExitCode: 0, + expectedError: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + script := fmt.Sprintf(` + import { browser } from 'k6/experimental/browser'; + + %s + + export default function() { + browser.isConnected(); + }; + `, tt.options) + + ts := getSingleFileTestState(t, script, []string{}, tt.expectedExitCode) + cmd.ExecuteWithGlobalState(ts.GlobalState) + loglines := ts.LoggerHook.Drain() + + if tt.expectedError == "" { + require.Len(t, loglines, 0) + return + } + + assert.Contains(t, loglines[0].Message, tt.expectedError) + }) + } +} diff --git a/go.mod b/go.mod index 4c0e0265791..f695973ab7e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/go-sourcemap/sourcemap v2.1.4-0.20211119122758-180fcef48034+incompatible github.com/golang/protobuf v1.5.3 github.com/gorilla/websocket v1.5.0 - github.com/grafana/xk6-browser v0.10.0 + github.com/grafana/xk6-browser v1.0.2 github.com/grafana/xk6-grpc v0.1.3-0.20230717090346-fb49221e0ce1 github.com/grafana/xk6-output-prometheus-remote v0.2.3 github.com/grafana/xk6-redis v0.1.1 diff --git a/go.sum b/go.sum index 97b8a1d1459..52bd36a6679 100644 --- a/go.sum +++ b/go.sum @@ -183,8 +183,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/xk6-browser v0.10.0 h1:Mnx0Ho+mlyFGlV7zW7zXkN0njRglh9JflLV+OzXSaRk= -github.com/grafana/xk6-browser v0.10.0/go.mod h1:ax6OHARpNEu9hSGYOAI4grAwiRapsNPi9TBQxDYurKw= +github.com/grafana/xk6-browser v1.0.2 h1:B9ll8xLH68hfCBy3sTzhmksCxwgJBIcqgPeX3mht6jM= +github.com/grafana/xk6-browser v1.0.2/go.mod h1:LV/ECGBCN3vRN/A4St+Ep9JUpbKJuRsj+6TBihQptGw= github.com/grafana/xk6-grpc v0.1.3-0.20230717090346-fb49221e0ce1 h1:SdMihJN+fkH6cO/1NeAnVxSVOnJ3ZkZ1v7FJnrcqhog= github.com/grafana/xk6-grpc v0.1.3-0.20230717090346-fb49221e0ce1/go.mod h1:iq6qHN64XgAEmDHKf0OXZ4mvoqF4Udr22fiCIXNpXA0= github.com/grafana/xk6-output-prometheus-remote v0.2.3 h1:ta4wFrO85+29H0papAbeMCavHrBuHDZ4bdKC1Zv8zlo= diff --git a/js/jsmodules.go b/js/jsmodules.go index c068db47a97..f5ec36a0d81 100644 --- a/js/jsmodules.go +++ b/js/jsmodules.go @@ -8,7 +8,6 @@ import ( "go.k6.io/k6/js/modules/k6/data" "go.k6.io/k6/js/modules/k6/encoding" "go.k6.io/k6/js/modules/k6/execution" - "go.k6.io/k6/js/modules/k6/experimental/browser" "go.k6.io/k6/js/modules/k6/experimental/tracing" "go.k6.io/k6/js/modules/k6/grpc" "go.k6.io/k6/js/modules/k6/html" @@ -16,6 +15,7 @@ import ( "go.k6.io/k6/js/modules/k6/metrics" "go.k6.io/k6/js/modules/k6/ws" + "github.com/grafana/xk6-browser/browser" expGrpc "github.com/grafana/xk6-grpc/grpc" "github.com/grafana/xk6-redis/redis" "github.com/grafana/xk6-timers/timers" diff --git a/js/modules/k6/experimental/browser/rootmodule.go b/js/modules/k6/experimental/browser/rootmodule.go deleted file mode 100644 index f0ba4f87260..00000000000 --- a/js/modules/k6/experimental/browser/rootmodule.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package browser contains a RootModule wrapper -// that wraps around the experimental browser -// RootModule. -package browser - -import ( - "errors" - "strconv" - - xk6browser "github.com/grafana/xk6-browser/browser" - "go.k6.io/k6/js/common" - "go.k6.io/k6/js/modules" -) - -type ( - // RootModule is a wrapper around the experimental - // browser RootModule. It will prevent browser test - // runs unless K6_BROWSER_ENABLED env var is set. - RootModule struct { - rm *xk6browser.RootModule - } -) - -// New creates an experimental browser RootModule -// and wraps it around this internal RootModule. -func New() *RootModule { - return &RootModule{ - rm: xk6browser.New(), - } -} - -// NewModuleInstance will check to see if -// K6_BROWSER_ENABLED is set before allowing -// test runs to continue. -func (r *RootModule) NewModuleInstance(vu modules.VU) modules.Instance { - env := vu.InitEnv() - - throwError := func() { - msg := "To run browser tests set env var K6_BROWSER_ENABLED=true" - if m, ok := env.LookupEnv("K6_BROWSER_ENABLED_MSG"); ok && m != "" { - msg = m - } - - common.Throw(vu.Runtime(), errors.New(msg)) - } - - vs, ok := env.LookupEnv("K6_BROWSER_ENABLED") - if !ok { - throwError() - } - - v, err := strconv.ParseBool(vs) - if err != nil || !v { - throwError() - } - - return r.rm.NewModuleInstance(vu) -} diff --git a/vendor/github.com/grafana/xk6-browser/api/browser.go b/vendor/github.com/grafana/xk6-browser/api/browser.go index 7b60fca75cd..a111a817c3e 100644 --- a/vendor/github.com/grafana/xk6-browser/api/browser.go +++ b/vendor/github.com/grafana/xk6-browser/api/browser.go @@ -5,7 +5,7 @@ import "github.com/dop251/goja" // Browser is the public interface of a CDP browser. type Browser interface { Close() - Contexts() []BrowserContext + Context() BrowserContext IsConnected() bool NewContext(opts goja.Value) (BrowserContext, error) NewPage(opts goja.Value) (Page, error) diff --git a/vendor/github.com/grafana/xk6-browser/api/browser_type.go b/vendor/github.com/grafana/xk6-browser/api/browser_type.go deleted file mode 100644 index 07f047c3da8..00000000000 --- a/vendor/github.com/grafana/xk6-browser/api/browser_type.go +++ /dev/null @@ -1,14 +0,0 @@ -package api - -import ( - "github.com/dop251/goja" -) - -// BrowserType is the public interface of a CDP browser client. -type BrowserType interface { - Connect(wsEndpoint string, opts goja.Value) Browser - ExecutablePath() string - Launch(opts goja.Value) (_ Browser, browserProcessID int) - LaunchPersistentContext(userDataDir string, opts goja.Value) Browser - Name() string -} diff --git a/vendor/github.com/grafana/xk6-browser/api/frame.go b/vendor/github.com/grafana/xk6-browser/api/frame.go index a8e44acb93e..5bf69b62b9c 100644 --- a/vendor/github.com/grafana/xk6-browser/api/frame.go +++ b/vendor/github.com/grafana/xk6-browser/api/frame.go @@ -1,6 +1,10 @@ package api -import "github.com/dop251/goja" +import ( + "context" + + "github.com/dop251/goja" +) // Frame is the interface of a CDP target frame. type Frame interface { @@ -12,6 +16,8 @@ type Frame interface { Content() string Dblclick(selector string, opts goja.Value) DispatchEvent(selector string, typ string, eventInit goja.Value, opts goja.Value) + // EvaluateWithContext for internal use only + EvaluateWithContext(ctx context.Context, pageFunc goja.Value, args ...goja.Value) (any, error) Evaluate(pageFunc goja.Value, args ...goja.Value) any EvaluateHandle(pageFunc goja.Value, args ...goja.Value) (JSHandle, error) Fill(selector string, value string, opts goja.Value) diff --git a/vendor/github.com/grafana/xk6-browser/browser/mapping.go b/vendor/github.com/grafana/xk6-browser/browser/mapping.go index fe8012c6ed9..2ce97453dff 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/mapping.go +++ b/vendor/github.com/grafana/xk6-browser/browser/mapping.go @@ -4,12 +4,10 @@ import ( "context" "errors" "fmt" - "os" "github.com/dop251/goja" "github.com/grafana/xk6-browser/api" - "github.com/grafana/xk6-browser/chromium" "github.com/grafana/xk6-browser/k6error" "github.com/grafana/xk6-browser/k6ext" @@ -34,12 +32,8 @@ func mapBrowserToGoja(vu moduleVU) *goja.Object { var ( rt = vu.Runtime() obj = rt.NewObject() - // TODO: Use k6 LookupEnv instead of OS package methods. - // See https://github.com/grafana/xk6-browser/issues/822. - wsURL, isRemoteBrowser = k6ext.IsRemoteBrowser(os.LookupEnv) - browserType = chromium.NewBrowserType(vu) ) - for k, v := range mapBrowserType(vu, browserType, wsURL, isRemoteBrowser) { + for k, v := range mapBrowser(vu) { err := obj.Set(k, rt.ToValue(v)) if err != nil { k6common.Throw(rt, fmt.Errorf("mapping: %w", err)) @@ -678,20 +672,28 @@ func mapBrowserContext(vu moduleVU, bc api.BrowserContext) mapping { } // mapBrowser to the JS module. -func mapBrowser(vu moduleVU, b api.Browser) mapping { +func mapBrowser(vu moduleVU) mapping { rt := vu.Runtime() return mapping{ - "close": b.Close, - "contexts": b.Contexts, - "isConnected": b.IsConnected, - "on": func(event string) *goja.Promise { - return k6ext.Promise(vu.Context(), func() (result any, reason error) { - return b.On(event) //nolint:wrapcheck - }) + "context": func() (api.BrowserContext, error) { + b, err := vu.browser() + if err != nil { + return nil, err + } + return b.Context(), nil + }, + "isConnected": func() (bool, error) { + b, err := vu.browser() + if err != nil { + return false, err + } + return b.IsConnected(), nil }, - "userAgent": b.UserAgent, - "version": b.Version, "newContext": func(opts goja.Value) (*goja.Object, error) { + b, err := vu.browser() + if err != nil { + return nil, err + } bctx, err := b.NewContext(opts) if err != nil { return nil, err //nolint:wrapcheck @@ -699,7 +701,25 @@ func mapBrowser(vu moduleVU, b api.Browser) mapping { m := mapBrowserContext(vu, bctx) return rt.ToValue(m).ToObject(rt), nil }, + "userAgent": func() (string, error) { + b, err := vu.browser() + if err != nil { + return "", err + } + return b.UserAgent(), nil + }, + "version": func() (string, error) { + b, err := vu.browser() + if err != nil { + return "", err + } + return b.Version(), nil + }, "newPage": func(opts goja.Value) (mapping, error) { + b, err := vu.browser() + if err != nil { + return nil, err + } page, err := b.NewPage(opts) if err != nil { return nil, err //nolint:wrapcheck @@ -709,36 +729,6 @@ func mapBrowser(vu moduleVU, b api.Browser) mapping { } } -// mapBrowserType to the JS module. -func mapBrowserType(vu moduleVU, bt api.BrowserType, wsURL string, isRemoteBrowser bool) mapping { - rt := vu.Runtime() - return mapping{ - "connect": func(wsEndpoint string, opts goja.Value) *goja.Object { - b := bt.Connect(wsEndpoint, opts) - m := mapBrowser(vu, b) - return rt.ToValue(m).ToObject(rt) - }, - "executablePath": bt.ExecutablePath, - "launchPersistentContext": bt.LaunchPersistentContext, - "name": bt.Name, - "launch": func(opts goja.Value) *goja.Object { - // If browser is remote, transition from launch - // to connect and avoid storing the browser pid - // as we have no access to it. - if isRemoteBrowser { - m := mapBrowser(vu, bt.Connect(wsURL, opts)) - return rt.ToValue(m).ToObject(rt) - } - - b, pid := bt.Launch(opts) - // store the pid so we can kill it later on panic. - vu.registerPid(pid) - m := mapBrowser(vu, b) - return rt.ToValue(m).ToObject(rt) - }, - } -} - func panicIfFatalError(ctx context.Context, err error) { if errors.Is(err, k6error.ErrFatal) { k6ext.Abort(ctx, err.Error()) diff --git a/vendor/github.com/grafana/xk6-browser/browser/module.go b/vendor/github.com/grafana/xk6-browser/browser/module.go index 8c310318967..1f25e3e8d6d 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/module.go +++ b/vendor/github.com/grafana/xk6-browser/browser/module.go @@ -1,10 +1,17 @@ -// Package browser provides an entry point to the browser extension. +// Package browser provides an entry point to the browser module. package browser import ( + "log" + "net/http" + _ "net/http/pprof" //nolint:gosec + "sync" + "github.com/dop251/goja" "github.com/grafana/xk6-browser/common" + "github.com/grafana/xk6-browser/env" + "github.com/grafana/xk6-browser/k6ext" k6modules "go.k6.io/k6/js/modules" ) @@ -13,14 +20,15 @@ type ( // RootModule is the global module instance that will create module // instances for each VU. RootModule struct { - PidRegistry *pidRegistry + PidRegistry *pidRegistry + remoteRegistry *remoteRegistry + initOnce *sync.Once } // JSModule exposes the properties available to the JS script. JSModule struct { - Chromium *goja.Object - Devices map[string]common.Device - Version string + Browser *goja.Object + Devices map[string]common.Device } // ModuleInstance represents an instance of the JS module. @@ -38,17 +46,27 @@ var ( func New() *RootModule { return &RootModule{ PidRegistry: &pidRegistry{}, + initOnce: &sync.Once{}, } } // NewModuleInstance implements the k6modules.Module interface to return // a new instance for each VU. func (m *RootModule) NewModuleInstance(vu k6modules.VU) k6modules.Instance { + // initialization should be done once per module as it initializes + // globally used values across the whole test run and not just the + // current VU. Since initialization can fail with an error, + // we've had to place it here so that if an error occurs a + // panic can be initiated and safely handled by k6. + m.initOnce.Do(func() { + m.initialize(vu) + }) return &ModuleInstance{ mod: &JSModule{ - Chromium: mapBrowserToGoja(moduleVU{ - VU: vu, - pidRegistry: m.PidRegistry, + Browser: mapBrowserToGoja(moduleVU{ + VU: vu, + pidRegistry: m.PidRegistry, + browserRegistry: newBrowserRegistry(vu, m.remoteRegistry, m.PidRegistry), }), Devices: common.GetDevices(), }, @@ -60,3 +78,25 @@ func (m *RootModule) NewModuleInstance(vu k6modules.VU) k6modules.Instance { func (mi *ModuleInstance) Exports() k6modules.Exports { return k6modules.Exports{Default: mi.mod} } + +// initialize initializes the module instance with a new remote registry +// and debug server, etc. +func (m *RootModule) initialize(vu k6modules.VU) { + var ( + err error + initEnv = vu.InitEnv() + ) + m.remoteRegistry, err = newRemoteRegistry(initEnv.LookupEnv) + if err != nil { + k6ext.Abort(vu.Context(), "failed to create remote registry: %v", err) + } + if _, ok := initEnv.LookupEnv(env.EnableProfiling); ok { + go startDebugServer() + } +} + +func startDebugServer() { + log.Println("Starting http debug server", env.ProfilingServerAddr) + log.Println(http.ListenAndServe(env.ProfilingServerAddr, nil)) //nolint:gosec + // no linted because we don't need to set timeouts for the debug server. +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/modulevu.go b/vendor/github.com/grafana/xk6-browser/browser/modulevu.go index 3c38d2aa39c..bf353c4569f 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/modulevu.go +++ b/vendor/github.com/grafana/xk6-browser/browser/modulevu.go @@ -3,6 +3,7 @@ package browser import ( "context" + "github.com/grafana/xk6-browser/api" "github.com/grafana/xk6-browser/k6ext" k6modules "go.k6.io/k6/js/modules" @@ -16,6 +17,12 @@ type moduleVU struct { k6modules.VU *pidRegistry + *browserRegistry +} + +// browser returns the VU browser instance for the current iteration. +func (vu moduleVU) browser() (api.Browser, error) { + return vu.browserRegistry.getBrowser(vu.State().Iteration) } func (vu moduleVU) Context() context.Context { diff --git a/vendor/github.com/grafana/xk6-browser/browser/pidregistry.go b/vendor/github.com/grafana/xk6-browser/browser/pidregistry.go deleted file mode 100644 index a505c53d0d0..00000000000 --- a/vendor/github.com/grafana/xk6-browser/browser/pidregistry.go +++ /dev/null @@ -1,28 +0,0 @@ -package browser - -import "sync" - -// pidRegistry keeps track of the launched browser process IDs. -type pidRegistry struct { - mu sync.RWMutex - ids []int -} - -// registerPid registers the launched browser process ID. -func (r *pidRegistry) registerPid(pid int) { - r.mu.Lock() - defer r.mu.Unlock() - - r.ids = append(r.ids, pid) -} - -// Pids returns the launched browser process IDs. -func (r *pidRegistry) Pids() []int { - r.mu.RLock() - defer r.mu.RUnlock() - - pids := make([]int, len(r.ids)) - copy(pids, r.ids) - - return pids -} diff --git a/vendor/github.com/grafana/xk6-browser/browser/registry.go b/vendor/github.com/grafana/xk6-browser/browser/registry.go new file mode 100644 index 00000000000..b21e29093bc --- /dev/null +++ b/vendor/github.com/grafana/xk6-browser/browser/registry.go @@ -0,0 +1,347 @@ +package browser + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/grafana/xk6-browser/api" + "github.com/grafana/xk6-browser/chromium" + "github.com/grafana/xk6-browser/env" + "github.com/grafana/xk6-browser/k6ext" + + k6event "go.k6.io/k6/event" + k6modules "go.k6.io/k6/js/modules" +) + +// errBrowserNotFoundInRegistry indicates that the browser instance +// for the iteration, which should have been initialized as a result +// of the IterStart event, has not been found in the registry. This +// might happen if browser type option is not set in scenario definition. +var errBrowserNotFoundInRegistry = errors.New("browser not found in registry. " + + "make sure to set browser type option in scenario definition in order to use the browser module") + +// pidRegistry keeps track of the launched browser process IDs. +type pidRegistry struct { + mu sync.RWMutex + ids []int +} + +// registerPid registers the launched browser process ID. +func (r *pidRegistry) registerPid(pid int) { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, pid) +} + +// Pids returns the launched browser process IDs. +func (r *pidRegistry) Pids() []int { + r.mu.RLock() + defer r.mu.RUnlock() + + pids := make([]int, len(r.ids)) + copy(pids, r.ids) + + return pids +} + +// remoteRegistry contains the details of the remote web browsers. +// At the moment it's the WS URLs. +type remoteRegistry struct { + isRemote bool + wsURLs []string +} + +// newRemoteRegistry will create a new RemoteRegistry. This will +// parse the K6_BROWSER_WS_URL env var to retrieve the defined +// list of WS URLs. +// +// K6_BROWSER_WS_URL can be defined as a single WS URL or a +// comma separated list of URLs. +func newRemoteRegistry(envLookup env.LookupFunc) (*remoteRegistry, error) { + r := &remoteRegistry{} + + isRemote, wsURLs, err := checkForScenarios(envLookup) + if err != nil { + return nil, err + } + if isRemote { + r.isRemote = isRemote + r.wsURLs = wsURLs + return r, nil + } + + r.isRemote, r.wsURLs = checkForBrowserWSURLs(envLookup) + + return r, nil +} + +func checkForBrowserWSURLs(envLookup env.LookupFunc) (bool, []string) { + wsURL, isRemote := envLookup(env.WebSocketURLs) + if !isRemote { + return false, nil + } + + if !strings.ContainsRune(wsURL, ',') { + return true, []string{wsURL} + } + + // If last parts element is a void string, + // because WS URL contained an ending comma, + // remove it + parts := strings.Split(wsURL, ",") + if parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + + return true, parts +} + +// checkForScenarios will parse the K6_INSTANCE_SCENARIOS env var if +// it has been defined. +func checkForScenarios(envLookup env.LookupFunc) (bool, []string, error) { + scenariosJSON, isRemote := envLookup(env.InstanceScenarios) + if !isRemote { + return false, nil, nil + } + // prevent failing in unquoting empty string. + if scenariosJSON == "" { + return false, nil, nil + } + scenariosJSON, err := strconv.Unquote(scenariosJSON) + if err != nil { + return false, nil, fmt.Errorf("unqouting K6_INSTANCE_SCENARIOS: %w", err) + } + + var scenarios []struct { + ID string `json:"id"` + Browsers []struct { + Handle string `json:"handle"` + } `json:"browsers"` + } + if err := json.Unmarshal([]byte(scenariosJSON), &scenarios); err != nil { + return false, nil, fmt.Errorf("parsing K6_INSTANCE_SCENARIOS: %w", err) + } + + var wsURLs []string + for _, s := range scenarios { + for _, b := range s.Browsers { + if strings.TrimSpace(b.Handle) == "" { + continue + } + wsURLs = append(wsURLs, b.Handle) + } + } + if len(wsURLs) == 0 { + return false, wsURLs, nil + } + + return true, wsURLs, nil +} + +// isRemoteBrowser returns a WS URL and true when a WS URL is defined, +// otherwise it returns an empty string and false. If more than one +// WS URL was registered in newRemoteRegistry, a randomly chosen URL from +// the list in a round-robin fashion is selected and returned. +func (r *remoteRegistry) isRemoteBrowser() (string, bool) { + if !r.isRemote { + return "", false + } + + // Choose a random WS URL from the provided list + i, _ := rand.Int(rand.Reader, big.NewInt(int64(len(r.wsURLs)))) + wsURL := r.wsURLs[i.Int64()] + + return wsURL, true +} + +// browserRegistry stores a single VU browser instances +// indexed per iteration. +type browserRegistry struct { + vu k6modules.VU + + mu sync.RWMutex + m map[int64]api.Browser + + buildFn browserBuildFunc + + stopped atomic.Bool // testing purposes +} + +type browserBuildFunc func(ctx context.Context) (api.Browser, error) + +func newBrowserRegistry(vu k6modules.VU, remote *remoteRegistry, pids *pidRegistry) *browserRegistry { + bt := chromium.NewBrowserType(vu) + builder := func(ctx context.Context) (api.Browser, error) { + var ( + err error + b api.Browser + wsURL, isRemoteBrowser = remote.isRemoteBrowser() + ) + + if isRemoteBrowser { + b, err = bt.Connect(ctx, wsURL) + if err != nil { + return nil, err //nolint:wrapcheck + } + } else { + var pid int + b, pid, err = bt.Launch(ctx) + if err != nil { + return nil, err //nolint:wrapcheck + } + pids.registerPid(pid) + } + + return b, nil + } + + r := &browserRegistry{ + vu: vu, + m: make(map[int64]api.Browser), + buildFn: builder, + } + + exitSubID, exitCh := vu.Events().Global.Subscribe( + k6event.Exit, + ) + iterSubID, eventsCh := vu.Events().Local.Subscribe( + k6event.IterStart, + k6event.IterEnd, + ) + unsubscribe := func() { + vu.Events().Local.Unsubscribe(iterSubID) + vu.Events().Global.Unsubscribe(exitSubID) + } + + go r.handleExitEvent(exitCh, unsubscribe) + go r.handleIterEvents(eventsCh, unsubscribe) + + return r +} + +func (r *browserRegistry) handleIterEvents(eventsCh <-chan *k6event.Event, unsubscribeFn func()) { + var ( + ok bool + data k6event.IterData + ctx = context.Background() + ) + + for e := range eventsCh { + // If browser module is imported in the test, NewModuleInstance will be called for + // every VU. Because on VU init stage we can not distinguish to which scenario it + // belongs or access its options (because state is nil), we have to always subscribe + // to each VU iter events, including VUs that do not make use of the browser in their + // iterations. + // Therefore, if we get an event that does not correspond to a browser iteration, then + // unsubscribe for the VU events and exit the loop in order to reduce unuseful overhead. + if !isBrowserIter(r.vu) { + unsubscribeFn() + r.stop() + e.Done() + return + } + + // The context in the VU is not thread safe. It can + // be safely accessed during an iteration but not + // before one is started. This is why it is being + // accessed and used here. + vuCtx := k6ext.WithVU(r.vu.Context(), r.vu) + + if data, ok = e.Data.(k6event.IterData); !ok { + e.Done() + k6ext.Abort(vuCtx, "unexpected iteration event data format: %v", e.Data) + // Continue so we don't block the k6 event system producer. + // Test will be aborted by k6, which will previously send the + // 'Exit' event so browser resources cleanup can be guaranteed. + continue + } + + switch e.Type { //nolint:exhaustive + case k6event.IterStart: + b, err := r.buildFn(ctx) + if err != nil { + e.Done() + k6ext.Abort(vuCtx, "error building browser on IterStart: %v", err) + // Continue so we don't block the k6 event system producer. + // Test will be aborted by k6, which will previously send the + // 'Exit' event so browser resources cleanup can be guaranteed. + continue + } + r.setBrowser(data.Iteration, b) + case k6event.IterEnd: + r.deleteBrowser(data.Iteration) + default: + r.vu.State().Logger.Warnf("received unexpected event type: %v", e.Type) + } + + e.Done() + } +} + +func (r *browserRegistry) handleExitEvent(exitCh <-chan *k6event.Event, unsubscribeFn func()) { + defer unsubscribeFn() + + e, ok := <-exitCh + if !ok { + return + } + defer e.Done() + r.clear() +} + +func (r *browserRegistry) setBrowser(id int64, b api.Browser) { + r.mu.Lock() + defer r.mu.Unlock() + + r.m[id] = b +} + +func (r *browserRegistry) getBrowser(id int64) (api.Browser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + if b, ok := r.m[id]; ok { + return b, nil + } + + return nil, errBrowserNotFoundInRegistry +} + +func (r *browserRegistry) deleteBrowser(id int64) { + r.mu.Lock() + defer r.mu.Unlock() + + if b, ok := r.m[id]; ok { + b.Close() + delete(r.m, id) + } +} + +func (r *browserRegistry) clear() { + r.mu.Lock() + defer r.mu.Unlock() + + for id, b := range r.m { + b.Close() + delete(r.m, id) + } +} + +func (r *browserRegistry) stop() { + r.stopped.Store(true) +} + +func isBrowserIter(vu k6modules.VU) bool { + opts := k6ext.GetScenarioOpts(vu.Context(), vu) + _, ok := opts["type"] // Check if browser type option is set + return ok +} diff --git a/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go b/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go index ba6b9fa9768..a994fc753ad 100644 --- a/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go +++ b/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "math/rand" - "os" "os/exec" "path/filepath" "sort" @@ -15,6 +14,7 @@ import ( "github.com/grafana/xk6-browser/api" "github.com/grafana/xk6-browser/common" + "github.com/grafana/xk6-browser/env" "github.com/grafana/xk6-browser/k6ext" "github.com/grafana/xk6-browser/log" "github.com/grafana/xk6-browser/storage" @@ -26,72 +26,71 @@ import ( "github.com/dop251/goja" ) -// Ensure BrowserType implements the api.BrowserType interface. -var _ api.BrowserType = &BrowserType{} - // BrowserType provides methods to launch a Chrome browser instance or connect to an existing one. // It's the entry point for interacting with the browser. type BrowserType struct { // FIXME: This is only exported because testBrowser needs it. Contexts // shouldn't be stored on structs if we can avoid it. - Ctx context.Context - vu k6modules.VU - hooks *common.Hooks - k6Metrics *k6ext.CustomMetrics - execPath string // path to the Chromium executable - randSrc *rand.Rand + Ctx context.Context + vu k6modules.VU + hooks *common.Hooks + k6Metrics *k6ext.CustomMetrics + execPath string // path to the Chromium executable + randSrc *rand.Rand + envLookupper env.LookupFunc } // NewBrowserType registers our custom k6 metrics, creates method mappings on // the goja runtime, and returns a new Chrome browser type. -func NewBrowserType(vu k6modules.VU) api.BrowserType { +func NewBrowserType(vu k6modules.VU) *BrowserType { // NOTE: vu.InitEnv() *must* be called from the script init scope, // otherwise it will return nil. - k6m := k6ext.RegisterCustomMetrics(vu.InitEnv().Registry) - b := BrowserType{ - vu: vu, - hooks: common.NewHooks(), - k6Metrics: k6m, - randSrc: rand.New(rand.NewSource(time.Now().UnixNano())), //nolint: gosec - } + env := vu.InitEnv() - return &b + return &BrowserType{ + vu: vu, + hooks: common.NewHooks(), + k6Metrics: k6ext.RegisterCustomMetrics(env.Registry), + randSrc: rand.New(rand.NewSource(time.Now().UnixNano())), //nolint: gosec + envLookupper: env.LookupEnv, + } } func (b *BrowserType) init( - opts goja.Value, isRemoteBrowser bool, -) (context.Context, *common.LaunchOptions, *log.Logger, error) { - ctx := b.initContext() + ctx context.Context, isRemoteBrowser bool, +) (context.Context, *common.BrowserOptions, *log.Logger, error) { + ctx = b.initContext(ctx) - logger, err := makeLogger(ctx) + logger, err := makeLogger(ctx, b.envLookupper) if err != nil { return nil, nil, nil, fmt.Errorf("error setting up logger: %w", err) } - var launchOpts *common.LaunchOptions + var browserOpts *common.BrowserOptions if isRemoteBrowser { - launchOpts = common.NewRemoteBrowserLaunchOptions() + browserOpts = common.NewRemoteBrowserOptions() } else { - launchOpts = common.NewLaunchOptions() + browserOpts = common.NewLocalBrowserOptions() } - if err = launchOpts.Parse(ctx, logger, opts); err != nil { - return nil, nil, nil, fmt.Errorf("error parsing launch options: %w", err) + opts := k6ext.GetScenarioOpts(b.vu.Context(), b.vu) + if err = browserOpts.Parse(ctx, logger, opts, b.envLookupper); err != nil { + return nil, nil, nil, fmt.Errorf("error parsing browser options: %w", err) } - ctx = common.WithLaunchOptions(ctx, launchOpts) + ctx = common.WithBrowserOptions(ctx, browserOpts) - if err := logger.SetCategoryFilter(launchOpts.LogCategoryFilter); err != nil { + if err := logger.SetCategoryFilter(browserOpts.LogCategoryFilter); err != nil { return nil, nil, nil, fmt.Errorf("error setting category filter: %w", err) } - if launchOpts.Debug { + if browserOpts.Debug { _ = logger.SetLevel("debug") } - return ctx, launchOpts, logger, nil + return ctx, browserOpts, logger, nil } -func (b *BrowserType) initContext() context.Context { - ctx := k6ext.WithVU(b.vu.Context(), b.vu) +func (b *BrowserType) initContext(ctx context.Context) context.Context { + ctx = k6ext.WithVU(ctx, b.vu) ctx = k6ext.WithCustomMetrics(ctx, b.k6Metrics) ctx = common.WithHooks(ctx, b.hooks) ctx = common.WithIterationID(ctx, fmt.Sprintf("%x", b.randSrc.Uint64())) @@ -99,28 +98,28 @@ func (b *BrowserType) initContext() context.Context { } // Connect attaches k6 browser to an existing browser instance. -func (b *BrowserType) Connect(wsEndpoint string, opts goja.Value) api.Browser { - ctx, launchOpts, logger, err := b.init(opts, true) +func (b *BrowserType) Connect(ctx context.Context, wsEndpoint string) (api.Browser, error) { + ctx, browserOpts, logger, err := b.init(ctx, true) if err != nil { - k6ext.Panic(ctx, "initializing browser type: %w", err) + return nil, fmt.Errorf("initializing browser type: %w", err) } - bp, err := b.connect(ctx, wsEndpoint, launchOpts, logger) + bp, err := b.connect(ctx, wsEndpoint, browserOpts, logger) if err != nil { err = &k6ext.UserFriendlyError{ Err: err, - Timeout: launchOpts.Timeout, + Timeout: browserOpts.Timeout, } - k6ext.Panic(ctx, "%w", err) + return nil, fmt.Errorf("%w", err) } - return bp + return bp, nil } func (b *BrowserType) connect( - ctx context.Context, wsURL string, opts *common.LaunchOptions, logger *log.Logger, + ctx context.Context, wsURL string, opts *common.BrowserOptions, logger *log.Logger, ) (*common.Browser, error) { - browserProc, err := b.link(ctx, wsURL, opts, logger) + browserProc, err := b.link(ctx, wsURL, logger) if browserProc == nil { return nil, fmt.Errorf("connecting to browser: %w", err) } @@ -140,10 +139,9 @@ func (b *BrowserType) connect( } func (b *BrowserType) link( - ctx context.Context, wsURL string, - opts *common.LaunchOptions, logger *log.Logger, + ctx context.Context, wsURL string, logger *log.Logger, ) (*common.BrowserProcess, error) { - bProcCtx, bProcCtxCancel := context.WithTimeout(ctx, opts.Timeout) + bProcCtx, bProcCtxCancel := context.WithCancel(ctx) p, err := common.NewRemoteBrowserProcess(bProcCtx, wsURL, bProcCtxCancel, logger) if err != nil { bProcCtxCancel() @@ -155,56 +153,39 @@ func (b *BrowserType) link( // Launch allocates a new Chrome browser process and returns a new api.Browser value, // which can be used for controlling the Chrome browser. -func (b *BrowserType) Launch(opts goja.Value) (_ api.Browser, browserProcessID int) { - ctx, launchOpts, logger, err := b.init(opts, false) +func (b *BrowserType) Launch(ctx context.Context) (_ api.Browser, browserProcessID int, _ error) { + ctx, browserOpts, logger, err := b.init(ctx, false) if err != nil { - k6ext.Panic(ctx, "initializing browser type: %w", err) + return nil, 0, fmt.Errorf("initializing browser type: %w", err) } - bp, pid, err := b.launch(ctx, launchOpts, logger) + bp, pid, err := b.launch(ctx, browserOpts, logger) if err != nil { err = &k6ext.UserFriendlyError{ Err: err, - Timeout: launchOpts.Timeout, + Timeout: browserOpts.Timeout, } - k6ext.Panic(ctx, "%w", err) + return nil, 0, fmt.Errorf("%w", err) } - return bp, pid + return bp, pid, nil } func (b *BrowserType) launch( - ctx context.Context, opts *common.LaunchOptions, logger *log.Logger, + ctx context.Context, opts *common.BrowserOptions, logger *log.Logger, ) (_ *common.Browser, pid int, _ error) { - envs := make([]string, 0, len(opts.Env)) - for k, v := range opts.Env { - envs = append(envs, fmt.Sprintf("%s=%s", k, v)) - } flags, err := prepareFlags(opts, &(b.vu.State()).Options) if err != nil { return nil, 0, fmt.Errorf("%w", err) } + dataDir := &storage.Dir{} - if err := dataDir.Make("", flags["user-data-dir"]); err != nil { + if err := dataDir.Make(b.tmpdir(), flags["user-data-dir"]); err != nil { return nil, 0, fmt.Errorf("%w", err) } flags["user-data-dir"] = dataDir.Dir - go func(c context.Context) { - defer func() { - if err := dataDir.Cleanup(); err != nil { - logger.Errorf("BrowserType:Launch", "cleaning up the user data directory: %v", err) - } - }() - // There's a small chance that this might be called - // if the context is closed by the k6 runtime. To - // guarantee the cleanup we would need to orchestrate - // it correctly which https://github.com/grafana/k6/issues/2432 - // will enable once it's complete. - <-c.Done() - }(ctx) - - browserProc, err := b.allocate(ctx, opts, flags, envs, dataDir, logger) + browserProc, err := b.allocate(ctx, opts, flags, dataDir, logger) if browserProc == nil { return nil, 0, fmt.Errorf("launching browser: %w", err) } @@ -222,6 +203,14 @@ func (b *BrowserType) launch( return browser, browserProc.Pid(), nil } +// tmpdir returns the temporary directory to use for the browser. +// It returns the value of the TMPDIR environment variable if set, +// otherwise it returns an empty string. +func (b *BrowserType) tmpdir() string { + dir, _ := b.envLookupper("TMPDIR") + return dir +} + // LaunchPersistentContext launches the browser with persistent storage. func (b *BrowserType) LaunchPersistentContext(userDataDir string, opts goja.Value) api.Browser { rt := b.vu.Runtime() @@ -236,11 +225,11 @@ func (b *BrowserType) Name() string { // allocate starts a new Chromium browser process and returns it. func (b *BrowserType) allocate( - ctx context.Context, opts *common.LaunchOptions, - flags map[string]any, env []string, dataDir *storage.Dir, + ctx context.Context, opts *common.BrowserOptions, + flags map[string]any, dataDir *storage.Dir, logger *log.Logger, ) (_ *common.BrowserProcess, rerr error) { - bProcCtx, bProcCtxCancel := context.WithTimeout(ctx, opts.Timeout) + bProcCtx, bProcCtxCancel := context.WithCancel(ctx) defer func() { if rerr != nil { bProcCtxCancel() @@ -257,7 +246,7 @@ func (b *BrowserType) allocate( path = b.ExecutablePath() } - return common.NewLocalBrowserProcess(bProcCtx, path, args, env, dataDir, bProcCtxCancel, logger) //nolint: wrapcheck + return common.NewLocalBrowserProcess(bProcCtx, path, args, dataDir, bProcCtxCancel, logger) //nolint: wrapcheck } // ExecutablePath returns the path where the extension expects to find the browser executable. @@ -269,7 +258,7 @@ func (b *BrowserType) ExecutablePath() (execPath string) { b.execPath = execPath }() - for _, path := range [...]string{ + paths := []string{ // Unix-like "headless_shell", "headless-shell", @@ -280,18 +269,19 @@ func (b *BrowserType) ExecutablePath() (execPath string) { "google-chrome-beta", "google-chrome-unstable", "/usr/bin/google-chrome", - // Windows "chrome", "chrome.exe", // in case PATHEXT is misconfigured `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, `C:\Program Files\Google\Chrome\Application\chrome.exe`, - filepath.Join(os.Getenv("USERPROFILE"), `AppData\Local\Google\Chrome\Application\chrome.exe`), - // Mac (from https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Mac/857950/) "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", - } { + } + if userProfile, ok := b.envLookupper("USERPROFILE"); ok { + paths = append(paths, filepath.Join(userProfile, `AppData\Local\Google\Chrome\Application\chrome.exe`)) + } + for _, path := range paths { if _, err := exec.LookPath(path); err == nil { return path } @@ -322,12 +312,12 @@ func parseArgs(flags map[string]any) ([]string, error) { // Force the first page to be blank, instead of the welcome page; // --no-first-run doesn't enforce that. - // args = append(args, "about:blank") + // args = append(args, common.BlankPage) // args = append(args, "--no-startup-window") return args, nil } -func prepareFlags(lopts *common.LaunchOptions, k6opts *k6lib.Options) (map[string]any, error) { +func prepareFlags(lopts *common.BrowserOptions, k6opts *k6lib.Options) (map[string]any, error) { // After Puppeteer's and Playwright's default behavior. f := map[string]any{ "disable-background-networking": true, @@ -354,11 +344,10 @@ func prepareFlags(lopts *common.LaunchOptions, k6opts *k6lib.Options) (map[strin "use-mock-keychain": true, "no-service-autorun": true, - "no-startup-window": true, - "no-default-browser-check": true, - "headless": lopts.Headless, - "auto-open-devtools-for-tabs": lopts.Devtools, - "window-size": fmt.Sprintf("%d,%d", 800, 600), + "no-startup-window": true, + "no-default-browser-check": true, + "headless": lopts.Headless, + "window-size": fmt.Sprintf("%d,%d", 800, 600), } if lopts.Headless { f["hide-scrollbars"] = true @@ -440,12 +429,12 @@ func setFlagsFromK6Options(flags map[string]any, k6opts *k6lib.Options) error { } // makeLogger makes and returns an extension wide logger. -func makeLogger(ctx context.Context) (*log.Logger, error) { +func makeLogger(ctx context.Context, envLookup env.LookupFunc) (*log.Logger, error) { var ( k6Logger = k6ext.GetVU(ctx).State().Logger logger = log.New(k6Logger, common.GetIterationID(ctx)) ) - if el, ok := os.LookupEnv("XK6_BROWSER_LOG"); ok { + if el, ok := envLookup(env.LogLevel); ok { if logger.SetLevel(el) != nil { return nil, fmt.Errorf( "invalid log level %q, should be one of: panic, fatal, error, warn, warning, info, debug, trace", @@ -453,7 +442,7 @@ func makeLogger(ctx context.Context) (*log.Logger, error) { ) } } - if _, ok := os.LookupEnv("XK6_BROWSER_CALLER"); ok { + if _, ok := envLookup(env.LogCaller); ok { logger.ReportCaller() } diff --git a/vendor/github.com/grafana/xk6-browser/common/browser.go b/vendor/github.com/grafana/xk6-browser/common/browser.go index 3f0a845e417..a0a0598c09c 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser.go +++ b/vendor/github.com/grafana/xk6-browser/common/browser.go @@ -25,8 +25,7 @@ import ( // Ensure Browser implements the EventEmitter and Browser interfaces. var ( - _ EventEmitter = &Browser{} - _ api.Browser = &Browser{} + _ api.Browser = &Browser{} ) const ( @@ -36,22 +35,19 @@ const ( // Browser stores a Browser context. type Browser struct { - BaseEventEmitter - ctx context.Context cancelFn context.CancelFunc state int64 browserProc *BrowserProcess - launchOpts *LaunchOptions + browserOpts *BrowserOptions // Connection to the browser to talk CDP protocol. // A *Connection is saved to this field, see: connect(). conn connection - contextsMu sync.RWMutex - contexts map[cdp.BrowserContextID]*BrowserContext + context *BrowserContext defaultContext *BrowserContext // Cancel function to stop event listening @@ -78,10 +74,10 @@ func NewBrowser( ctx context.Context, cancel context.CancelFunc, browserProc *BrowserProcess, - launchOpts *LaunchOptions, + browserOpts *BrowserOptions, logger *log.Logger, ) (*Browser, error) { - b := newBrowser(ctx, cancel, browserProc, launchOpts, logger) + b := newBrowser(ctx, cancel, browserProc, browserOpts, logger) if err := b.connect(); err != nil { return nil, err } @@ -93,17 +89,15 @@ func newBrowser( ctx context.Context, cancelFn context.CancelFunc, browserProc *BrowserProcess, - launchOpts *LaunchOptions, + browserOpts *BrowserOptions, logger *log.Logger, ) *Browser { return &Browser{ - BaseEventEmitter: NewBaseEventEmitter(ctx), ctx: ctx, cancelFn: cancelFn, state: int64(BrowserStateOpen), browserProc: browserProc, - launchOpts: launchOpts, - contexts: make(map[cdp.BrowserContextID]*BrowserContext), + browserOpts: browserOpts, pages: make(map[target.ID]*Page), sessionIDtoTargetID: make(map[target.SessionID]target.ID), vu: k6ext.GetVU(ctx), @@ -137,23 +131,19 @@ func (b *Browser) disposeContext(id cdp.BrowserContextID) error { return fmt.Errorf("disposing browser context ID %s: %w", id, err) } - b.contextsMu.Lock() - defer b.contextsMu.Unlock() - delete(b.contexts, id) + b.context = nil return nil } -// getDefaultBrowserContextOrByID returns the BrowserContext for the given page ID. +// getDefaultBrowserContextOrMatchedID returns the BrowserContext for the given browser context ID. // If the browser context is not found, the default BrowserContext is returned. -func (b *Browser) getDefaultBrowserContextOrByID(id cdp.BrowserContextID) *BrowserContext { - b.contextsMu.RLock() - defer b.contextsMu.RUnlock() - browserCtx := b.defaultContext - if bctx, ok := b.contexts[id]; ok { - browserCtx = bctx - } - return browserCtx +func (b *Browser) getDefaultBrowserContextOrMatchedID(id cdp.BrowserContextID) *BrowserContext { + if b.context == nil || b.context.id != id { + return b.defaultContext + } + + return b.context } func (b *Browser) getPages() []*Page { @@ -227,7 +217,7 @@ func (b *Browser) onAttachedToTarget(ev *target.EventAttachedToTarget) { var ( targetPage = ev.TargetInfo - browserCtx = b.getDefaultBrowserContextOrByID(targetPage.BrowserContextID) + browserCtx = b.getDefaultBrowserContextOrMatchedID(targetPage.BrowserContextID) ) if !b.isAttachedPageValid(ev, browserCtx) { @@ -235,7 +225,7 @@ func (b *Browser) onAttachedToTarget(ev *target.EventAttachedToTarget) { } session := b.conn.getSession(ev.SessionID) if session == nil { - b.logger.Warnf("Browser:onAttachedToTarget", + b.logger.Debugf("Browser:onAttachedToTarget", "session closed before attachToTarget is handled. sid:%v tid:%v", ev.SessionID, targetPage.TargetID) return // ignore @@ -377,14 +367,11 @@ func (b *Browser) onDetachedFromTarget(ev *target.EventDetachedFromTarget) { } func (b *Browser) newPageInContext(id cdp.BrowserContextID) (*Page, error) { - b.contextsMu.RLock() - browserCtx, ok := b.contexts[id] - b.contextsMu.RUnlock() - if !ok { - return nil, fmt.Errorf("missing browser context: %s", id) + if b.context == nil || b.context.id != id { + return nil, fmt.Errorf("missing browser context %s, current context is %s", id, b.context.id) } - ctx, cancel := context.WithTimeout(b.ctx, b.launchOpts.Timeout) + ctx, cancel := context.WithTimeout(b.ctx, b.browserOpts.Timeout) defer cancel() // buffer of one is for sending the target ID whether an event handler @@ -393,7 +380,7 @@ func (b *Browser) newPageInContext(id cdp.BrowserContextID) (*Page, error) { waitForPage, removeEventHandler := createWaitForEventHandler( ctx, - browserCtx, // browser context will emit the following event: + b.context, // browser context will emit the following event: []string{EventBrowserContextPage}, func(e any) bool { tid := <-targetID @@ -408,7 +395,7 @@ func (b *Browser) newPageInContext(id cdp.BrowserContextID) (*Page, error) { defer removeEventHandler() // create a new page. - action := target.CreateTarget("about:blank").WithBrowserContextID(id) + action := target.CreateTarget(BlankPage).WithBrowserContextID(id) tid, err := action.Do(cdp.WithExecutor(ctx, b.conn)) if err != nil { return nil, fmt.Errorf("creating a new blank page: %w", err) @@ -425,7 +412,7 @@ func (b *Browser) newPageInContext(id cdp.BrowserContextID) (*Page, error) { case <-ctx.Done(): err = &k6ext.UserFriendlyError{ Err: ctx.Err(), - Timeout: b.launchOpts.Timeout, + Timeout: b.browserOpts.Timeout, } b.logger.Debugf("Browser:newPageInContext:<-ctx.Done", "tid:%v bctxid:%v err:%v", tid, id, err) } @@ -462,11 +449,11 @@ func (b *Browser) Close() { // If the browser is not being executed remotely, send the Browser.close CDP // command, which triggers the browser process to exit. - if !b.launchOpts.isRemoteBrowser { + if !b.browserOpts.isRemoteBrowser { var closeErr *websocket.CloseError err := cdpbrowser.Close().Do(cdp.WithExecutor(b.ctx, b.conn)) if err != nil && !errors.As(err, &closeErr) { - k6ext.Panic(b.ctx, "closing the browser: %v", err) + b.logger.Errorf("Browser:Close", "closing the browser: %v", err) } } @@ -492,17 +479,9 @@ func (b *Browser) Close() { b.conn.Close() } -// Contexts returns list of browser contexts. -func (b *Browser) Contexts() []api.BrowserContext { - b.contextsMu.RLock() - defer b.contextsMu.RUnlock() - - contexts := make([]api.BrowserContext, 0, len(b.contexts)) - for _, b := range b.contexts { - contexts = append(contexts, b) - } - - return contexts +// Context returns the current browser context or nil. +func (b *Browser) Context() api.BrowserContext { + return b.context } // IsConnected returns whether the WebSocket connection to the browser process @@ -513,6 +492,10 @@ func (b *Browser) IsConnected() bool { // NewContext creates a new incognito-like browser context. func (b *Browser) NewContext(opts goja.Value) (api.BrowserContext, error) { + if b.context != nil { + return nil, errors.New("existing browser context must be closed before creating a new one") + } + action := target.CreateBrowserContext().WithDisposeOnDetach(true) browserContextID, err := action.Do(cdp.WithExecutor(b.ctx, b.conn)) b.logger.Debugf("Browser:NewContext", "bctxid:%v", browserContextID) @@ -525,13 +508,11 @@ func (b *Browser) NewContext(opts goja.Value) (api.BrowserContext, error) { k6ext.Panic(b.ctx, "parsing newContext options: %w", err) } - b.contextsMu.Lock() - defer b.contextsMu.Unlock() browserCtx, err := NewBrowserContext(b.ctx, b, browserContextID, browserCtxOpts, b.logger) if err != nil { return nil, fmt.Errorf("new context: %w", err) } - b.contexts[browserContextID] = browserCtx + b.context = browserCtx return browserCtx, nil } diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_context.go b/vendor/github.com/grafana/xk6-browser/common/browser_context.go index b98e8a6daca..82a4540b8fe 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_context.go +++ b/vendor/github.com/grafana/xk6-browser/common/browser_context.go @@ -368,7 +368,7 @@ func (b *BrowserContext) WaitForEvent(event string, optsOrPredicate goja.Value) isCallable bool predicateFn goja.Callable // TODO: Find out whether * time.Second is necessary. - timeout = b.browser.launchOpts.Timeout * time.Second //nolint:durationcheck + timeout = b.browser.browserOpts.Timeout * time.Second //nolint:durationcheck ) if gojaValueExists(optsOrPredicate) { switch optsOrPredicate.ExportType() { diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_options.go b/vendor/github.com/grafana/xk6-browser/common/browser_options.go index 0732090c853..2a0c26282aa 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_options.go +++ b/vendor/github.com/grafana/xk6-browser/common/browser_options.go @@ -2,74 +2,51 @@ package common import ( "context" + "errors" + "fmt" + "strconv" + "strings" "time" - "github.com/dop251/goja" - - "github.com/grafana/xk6-browser/k6ext" + "github.com/grafana/xk6-browser/env" "github.com/grafana/xk6-browser/log" -) -const ( - optArgs = "args" - optDebug = "debug" - optDevTools = "devtools" - optEnv = "env" - optExecutablePath = "executablePath" - optHeadless = "headless" - optIgnoreDefaultArgs = "ignoreDefaultArgs" - optLogCategoryFilter = "logCategoryFilter" - optProxy = "proxy" - optSlowMo = "slowMo" - optTimeout = "timeout" + "go.k6.io/k6/lib/types" ) -// ProxyOptions allows configuring a proxy server. -type ProxyOptions struct { - Server string - Bypass string - Username string - Password string -} +// Script variables. +const optType = "type" -// LaunchOptions stores browser launch options. -type LaunchOptions struct { +// BrowserOptions stores browser options. +type BrowserOptions struct { Args []string Debug bool - Devtools bool - Env map[string]string ExecutablePath string Headless bool IgnoreDefaultArgs []string LogCategoryFilter string - Proxy ProxyOptions - SlowMo time.Duration - Timeout time.Duration + // TODO: Do not expose slowMo option by now. + // See https://github.com/grafana/xk6-browser/issues/857. + SlowMo time.Duration + Timeout time.Duration isRemoteBrowser bool // some options will be ignored if browser is in a remote machine } -// LaunchPersistentContextOptions stores browser launch options for persistent context. -type LaunchPersistentContextOptions struct { - LaunchOptions - BrowserContextOptions -} - -// NewLaunchOptions returns a new LaunchOptions. -func NewLaunchOptions() *LaunchOptions { - return &LaunchOptions{ - Env: make(map[string]string), +// NewLocalBrowserOptions returns a new BrowserOptions +// for a browser launched in the local machine. +func NewLocalBrowserOptions() *BrowserOptions { + return &BrowserOptions{ Headless: true, LogCategoryFilter: ".*", Timeout: DefaultTimeout, } } -// NewRemoteBrowserLaunchOptions returns a new LaunchOptions +// NewRemoteBrowserOptions returns a new BrowserOptions // for a browser running in a remote machine. -func NewRemoteBrowserLaunchOptions() *LaunchOptions { - return &LaunchOptions{ - Env: make(map[string]string), +func NewRemoteBrowserOptions() *BrowserOptions { + return &BrowserOptions{ Headless: true, LogCategoryFilter: ".*", Timeout: DefaultTimeout, @@ -77,58 +54,58 @@ func NewRemoteBrowserLaunchOptions() *LaunchOptions { } } -// Parse parses launch options from a JS object. -func (l *LaunchOptions) Parse(ctx context.Context, logger *log.Logger, opts goja.Value) error { //nolint:cyclop - // when opts is nil, we just return the default options without error. - if !gojaValueExists(opts) { - return nil +// Parse parses browser options from a JS object. +func (bo *BrowserOptions) Parse( //nolint:cyclop + ctx context.Context, logger *log.Logger, opts map[string]any, envLookup env.LookupFunc, +) error { + // Parse opts + bt, ok := opts[optType] + // Only 'chromium' is supported by now, so return error + // if type option is not set, or if it's set and its value + // is different than 'chromium' + if !ok { + return errors.New("browser type option must be set") } - var ( - rt = k6ext.Runtime(ctx) - o = opts.ToObject(rt) - defaults = map[string]any{ - optEnv: l.Env, - optHeadless: l.Headless, - optLogCategoryFilter: l.LogCategoryFilter, - optTimeout: l.Timeout, - } - ) - for _, k := range o.Keys() { - if l.shouldIgnoreIfBrowserIsRemote(k) { - logger.Warnf("LaunchOptions", "setting %s option is disallowed when browser is remote", k) + if bt != "chromium" { + return fmt.Errorf("unsupported browser type: %s", bt) + } + + // Parse env + envOpts := [...]string{ + env.BrowserArguments, + env.BrowserEnableDebugging, + env.BrowserExecutablePath, + env.BrowserHeadless, + env.BrowserIgnoreDefaultArgs, + env.LogCategoryFilter, + env.BrowserGlobalTimeout, + } + + for _, e := range envOpts { + ev, ok := envLookup(e) + if !ok || ev == "" { continue } - v := o.Get(k) - if v.Export() == nil { - if dv, ok := defaults[k]; ok { - logger.Warnf("LaunchOptions", "%s was null and set to its default: %v", k, dv) - } + if bo.shouldIgnoreIfBrowserIsRemote(e) { + logger.Warnf("BrowserOptions", "setting %s option is disallowed when browser is remote", e) continue } var err error - switch k { - case optArgs: - err = exportOpt(rt, k, v, &l.Args) - case optDebug: - l.Debug, err = parseBoolOpt(k, v) - case optDevTools: - l.Devtools, err = parseBoolOpt(k, v) - case optEnv: - err = exportOpt(rt, k, v, &l.Env) - case optExecutablePath: - l.ExecutablePath, err = parseStrOpt(k, v) - case optHeadless: - l.Headless, err = parseBoolOpt(k, v) - case optIgnoreDefaultArgs: - err = exportOpt(rt, k, v, &l.IgnoreDefaultArgs) - case optLogCategoryFilter: - l.LogCategoryFilter, err = parseStrOpt(k, v) - case optProxy: - err = exportOpt(rt, k, v, &l.Proxy) - case optSlowMo: - l.SlowMo, err = parseTimeOpt(k, v) - case optTimeout: - l.Timeout, err = parseTimeOpt(k, v) + switch e { + case env.BrowserArguments: + bo.Args = parseListOpt(ev) + case env.BrowserEnableDebugging: + bo.Debug, err = parseBoolOpt(e, ev) + case env.BrowserExecutablePath: + bo.ExecutablePath = ev + case env.BrowserHeadless: + bo.Headless, err = parseBoolOpt(e, ev) + case env.BrowserIgnoreDefaultArgs: + bo.IgnoreDefaultArgs = parseListOpt(ev) + case env.LogCategoryFilter: + bo.LogCategoryFilter = ev + case env.BrowserGlobalTimeout: + bo.Timeout, err = parseTimeOpt(e, ev) } if err != nil { return err @@ -138,21 +115,48 @@ func (l *LaunchOptions) Parse(ctx context.Context, logger *log.Logger, opts goja return nil } -func (l *LaunchOptions) shouldIgnoreIfBrowserIsRemote(opt string) bool { - if !l.isRemoteBrowser { +func (bo *BrowserOptions) shouldIgnoreIfBrowserIsRemote(opt string) bool { + if !bo.isRemoteBrowser { return false } shouldIgnoreIfBrowserIsRemote := map[string]struct{}{ - optArgs: {}, - optDevTools: {}, - optEnv: {}, - optExecutablePath: {}, - optHeadless: {}, - optIgnoreDefaultArgs: {}, - optProxy: {}, + env.BrowserArguments: {}, + env.BrowserExecutablePath: {}, + env.BrowserHeadless: {}, + env.BrowserIgnoreDefaultArgs: {}, } _, ignore := shouldIgnoreIfBrowserIsRemote[opt] return ignore } + +func parseBoolOpt(k, v string) (bool, error) { + b, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("%s should be a boolean", k) + } + + return b, nil +} + +func parseTimeOpt(k, v string) (time.Duration, error) { + t, err := types.GetDurationValue(v) + if err != nil { + return time.Duration(0), fmt.Errorf("%s should be a time duration value: %w", k, err) + } + + return t, nil +} + +func parseListOpt(v string) []string { + elems := strings.Split(v, ",") + // If last element is a void string, + // because value contained an ending comma, + // remove it + if elems[len(elems)-1] == "" { + elems = elems[:len(elems)-1] + } + + return elems +} diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_process.go b/vendor/github.com/grafana/xk6-browser/common/browser_process.go index 47e0862aba3..0a82bb566e1 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_process.go +++ b/vendor/github.com/grafana/xk6-browser/common/browser_process.go @@ -35,10 +35,10 @@ type BrowserProcess struct { // NewLocalBrowserProcess starts a local browser process and // returns a new BrowserProcess instance to interact with it. func NewLocalBrowserProcess( - ctx context.Context, path string, args, env []string, dataDir *storage.Dir, + ctx context.Context, path string, args []string, dataDir *storage.Dir, ctxCancel context.CancelFunc, logger *log.Logger, ) (*BrowserProcess, error) { - cmd, err := execute(ctx, ctxCancel, path, args, env, dataDir, logger) + cmd, err := execute(ctx, path, args, dataDir, logger) if err != nil { return nil, err } @@ -151,7 +151,7 @@ type command struct { } func execute( - ctx context.Context, ctxCancel func(), path string, args, env []string, + ctx context.Context, path string, args []string, dataDir *storage.Dir, logger *log.Logger, ) (command, error) { cmd := exec.CommandContext(ctx, path, args...) @@ -166,11 +166,6 @@ func execute( return command{}, fmt.Errorf("%w", err) } - // Set up environment variable for process - if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) - } - // We must start the cmd before calling cmd.Wait, as otherwise the two // can run into a data race. err = cmd.Start() diff --git a/vendor/github.com/grafana/xk6-browser/common/connection.go b/vendor/github.com/grafana/xk6-browser/common/connection.go index 756ce1e9df2..c1cecf9f3e1 100644 --- a/vendor/github.com/grafana/xk6-browser/common/connection.go +++ b/vendor/github.com/grafana/xk6-browser/common/connection.go @@ -28,6 +28,25 @@ const wsWriteBufferSize = 1 << 20 var _ EventEmitter = &Connection{} var _ cdp.Executor = &Connection{} +// Each connection needs its own msgID. A msgID will be used by the +// connection and associated sessions. When a CDP request is made to +// chrome, it's best to work with unique ids to avoid the Execute +// handlers working with the wrong response, or handlers deadlocking +// when their response is rerouted to the wrong handler. +// +// Use the msgIDGenerator interface to abstract `id` away. +type msgID struct { + id int64 +} + +func (m *msgID) newID() int64 { + return atomic.AddInt64(&m.id, 1) +} + +type msgIDGenerator interface { + newID() int64 +} + type executorEmitter interface { cdp.Executor EventEmitter @@ -112,7 +131,7 @@ type Connection struct { done chan struct{} closing chan struct{} shutdownOnce sync.Once - msgID int64 + msgIDGen msgIDGenerator sessionsMu sync.RWMutex sessions map[target.SessionID]*Session @@ -150,7 +169,7 @@ func NewConnection(ctx context.Context, wsURL string, logger *log.Logger) (*Conn errorCh: make(chan error), done: make(chan struct{}), closing: make(chan struct{}), - msgID: 0, + msgIDGen: &msgID{}, sessions: make(map[target.SessionID]*Session), } @@ -316,7 +335,7 @@ func (c *Connection) recvLoop() { sid, tid := eva.SessionID, eva.TargetInfo.TargetID c.sessionsMu.Lock() - session := NewSession(c.ctx, c, sid, tid, c.logger) + session := NewSession(c.ctx, c, sid, tid, c.logger, c.msgIDGen) c.logger.Debugf("Connection:recvLoop:EventAttachedToTarget", "sid:%v tid:%v wsURL:%q", sid, tid, c.wsURL) c.sessions[sid] = session c.sessionsMu.Unlock() @@ -496,7 +515,7 @@ func (c *Connection) Close(args ...goja.Value) { // Execute implements cdproto.Executor and performs a synchronous send and receive. func (c *Connection) Execute(ctx context.Context, method string, params easyjson.Marshaler, res easyjson.Unmarshaler) error { c.logger.Debugf("connection:Execute", "wsURL:%q method:%q", c.wsURL, method) - id := atomic.AddInt64(&c.msgID, 1) + id := c.msgIDGen.newID() // Setup event handler used to block for response to message being sent. ch := make(chan *cdproto.Message, 1) diff --git a/vendor/github.com/grafana/xk6-browser/common/context.go b/vendor/github.com/grafana/xk6-browser/common/context.go index 8efe8689c74..56f8d643d6f 100644 --- a/vendor/github.com/grafana/xk6-browser/common/context.go +++ b/vendor/github.com/grafana/xk6-browser/common/context.go @@ -7,7 +7,7 @@ import ( type ctxKey int const ( - ctxKeyLaunchOptions ctxKey = iota + ctxKeyBrowserOptions ctxKey = iota ctxKeyHooks ctxKeyIterationID ) @@ -35,16 +35,21 @@ func GetIterationID(ctx context.Context) string { return s } -func WithLaunchOptions(ctx context.Context, opts *LaunchOptions) context.Context { - return context.WithValue(ctx, ctxKeyLaunchOptions, opts) +// WithBrowserOptions adds the browser options to the context. +func WithBrowserOptions(ctx context.Context, opts *BrowserOptions) context.Context { + return context.WithValue(ctx, ctxKeyBrowserOptions, opts) } -func GetLaunchOptions(ctx context.Context) *LaunchOptions { - v := ctx.Value(ctxKeyLaunchOptions) +// GetBrowserOptions returns the browser options attached to the context. +func GetBrowserOptions(ctx context.Context) *BrowserOptions { + v := ctx.Value(ctxKeyBrowserOptions) if v == nil { return nil } - return v.(*LaunchOptions) + if bo, ok := v.(*BrowserOptions); ok { + return bo + } + return nil } // contextWithDoneChan returns a new context that is canceled either diff --git a/vendor/github.com/grafana/xk6-browser/common/doc.go b/vendor/github.com/grafana/xk6-browser/common/doc.go new file mode 100644 index 00000000000..ed51123e2fb --- /dev/null +++ b/vendor/github.com/grafana/xk6-browser/common/doc.go @@ -0,0 +1,3 @@ +// Package common contains the implementation of API elements that do not +// depend on the browser type. +package common diff --git a/vendor/github.com/grafana/xk6-browser/common/frame.go b/vendor/github.com/grafana/xk6-browser/common/frame.go index e825e354a8e..dc1bfbfa22d 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame.go +++ b/vendor/github.com/grafana/xk6-browser/common/frame.go @@ -305,7 +305,10 @@ func (f *Frame) onLifecycleEvent(event LifecycleEvent) { f.lifecycleEvents[event] = true f.lifecycleEventsMu.Unlock() - f.emit(EventFrameAddLifecycle, event) + f.emit(EventFrameAddLifecycle, FrameLifecycleEvent{ + URL: f.URL(), + Event: event, + }) } func (f *Frame) onLoadingStarted() { @@ -706,9 +709,11 @@ func (f *Frame) dispatchEvent(selector, typ string, eventInit goja.Value, opts * return nil } -// Evaluate will evaluate provided page function within an execution context. -func (f *Frame) Evaluate(pageFunc goja.Value, args ...goja.Value) any { - f.log.Debugf("Frame:Evaluate", "fid:%s furl:%q", f.ID(), f.URL()) +// EvaluateWithContext will evaluate provided page function within an execution context. +// The passed in context will be used instead of the frame's context. The context must +// be a derivative of one that contains the goja runtime. +func (f *Frame) EvaluateWithContext(ctx context.Context, pageFunc goja.Value, args ...goja.Value) (any, error) { + f.log.Debugf("Frame:EvaluateWithContext", "fid:%s furl:%q", f.ID(), f.URL()) f.waitForExecutionContext(mainWorld) @@ -716,12 +721,24 @@ func (f *Frame) Evaluate(pageFunc goja.Value, args ...goja.Value) any { forceCallable: true, returnByValue: true, } - result, err := f.evaluate(f.ctx, mainWorld, opts, pageFunc, args...) + result, err := f.evaluate(ctx, mainWorld, opts, pageFunc, args...) if err != nil { - k6ext.Panic(f.ctx, "evaluating JS: %v", err) + return nil, fmt.Errorf("evaluating JS: %w", err) } - applySlowMo(f.ctx) + applySlowMo(ctx) + + return result, nil +} + +// Evaluate will evaluate provided page function within an execution context. +func (f *Frame) Evaluate(pageFunc goja.Value, args ...goja.Value) any { + f.log.Debugf("Frame:Evaluate", "fid:%s furl:%q", f.ID(), f.URL()) + + result, err := f.EvaluateWithContext(f.ctx, pageFunc, args...) + if err != nil { + k6ext.Panic(f.ctx, "%v", err) + } return result } @@ -1693,8 +1710,8 @@ func (f *Frame) WaitForLoadState(state string, opts goja.Value) { lifecycleEvtCh, lifecycleEvtCancel := createWaitForEventPredicateHandler( timeoutCtx, f, []string{EventFrameAddLifecycle}, func(data any) bool { - if le, ok := data.(LifecycleEvent); ok { - return le == waitUntil + if le, ok := data.(FrameLifecycleEvent); ok { + return le.Event == waitUntil } return false }) @@ -1736,8 +1753,8 @@ func (f *Frame) WaitForNavigation(opts goja.Value) (api.Response, error) { lifecycleEvtCh, lifecycleEvtCancel := createWaitForEventPredicateHandler( timeoutCtx, f, []string{EventFrameAddLifecycle}, func(data any) bool { - if le, ok := data.(LifecycleEvent); ok { - return le == parsedOpts.WaitUntil + if le, ok := data.(FrameLifecycleEvent); ok { + return le.Event == parsedOpts.WaitUntil } return false }) @@ -1773,7 +1790,7 @@ func (f *Frame) WaitForNavigation(opts goja.Value) (api.Response, error) { sameDocNav = true break } - // request could be nil if navigating to e.g. about:blank + // request could be nil if navigating to e.g. BlankPage. req := e.newDocument.request if req != nil { req.responseMu.RLock() diff --git a/vendor/github.com/grafana/xk6-browser/common/frame_manager.go b/vendor/github.com/grafana/xk6-browser/common/frame_manager.go index fb34edadd82..aee8c8a07f5 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame_manager.go +++ b/vendor/github.com/grafana/xk6-browser/common/frame_manager.go @@ -238,9 +238,11 @@ func (m *FrameManager) frameNavigated(frameID cdp.FrameID, parentFrameID cdp.Fra m.ID(), frameID, parentFrameID, documentID, name, url, initial) if frame != nil { + m.framesMu.Unlock() for _, child := range frame.ChildFrames() { m.removeFramesRecursively(child.(*Frame)) } + m.framesMu.Lock() } var mainFrame *Frame @@ -592,10 +594,23 @@ func (m *FrameManager) NavigateFrame(frame *Frame, url string, parsedOpts *Frame lifecycleEvtCh, lifecycleEvtCancel := createWaitForEventPredicateHandler( timeoutCtx, frame, []string{EventFrameAddLifecycle}, func(data any) bool { - if le, ok := data.(LifecycleEvent); ok { - return le == parsedOpts.WaitUntil + le, ok := data.(FrameLifecycleEvent) + if !ok { + return false } - return false + // skip the initial blank page if we are navigating to a non-blank page. + // otherwise, we will get a lifecycle event for the initial blank page + // and return prematurely before waiting for the navigation to complete. + if url != BlankPage && le.URL == BlankPage { + m.logger.Debugf( + "FrameManager:NavigateFrame:createWaitForEventPredicateHandler", + "fmid:%d fid:%v furl:%s url:%s waitUntil:%s event.lifecycle:%q event.url:%q skipping %s", + fmid, fid, furl, url, parsedOpts.WaitUntil, le.Event, le.URL, BlankPage, + ) + return false + } + + return le.Event == parsedOpts.WaitUntil }) defer lifecycleEvtCancel() @@ -646,7 +661,7 @@ func (m *FrameManager) NavigateFrame(frame *Frame, url string, parsedOpts *Frame case evt := <-navEvtCh: if e, ok := evt.(*NavigationEvent); ok { req := e.newDocument.request - // Request could be nil in case of navigation to e.g. about:blank + // Request could be nil in case of navigation to e.g. BlankPage. if req != nil { req.responseMu.RLock() resp = req.response diff --git a/vendor/github.com/grafana/xk6-browser/common/frame_session.go b/vendor/github.com/grafana/xk6-browser/common/frame_session.go index 969b56ab638..fa954b88d18 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame_session.go +++ b/vendor/github.com/grafana/xk6-browser/common/frame_session.go @@ -76,6 +76,8 @@ func NewFrameSession( ) (_ *FrameSession, err error) { l.Debugf("NewFrameSession", "sid:%v tid:%v", s.ID(), tid) + k6Metrics := k6ext.GetCustomMetrics(ctx) + fs := FrameSession{ ctx: ctx, // TODO: create cancelable context that can be used to cancel and close all child sessions session: s, @@ -89,7 +91,7 @@ func NewFrameSession( eventCh: make(chan Event), childSessions: make(map[cdp.FrameID]*FrameSession), vu: k6ext.GetVU(ctx), - k6Metrics: k6ext.GetCustomMetrics(ctx), + k6Metrics: k6Metrics, logger: l, serializer: l.ConsoleLogFormatterSerializer(), } @@ -98,7 +100,7 @@ func NewFrameSession( if fs.parent != nil { parentNM = fs.parent.networkManager } - fs.networkManager, err = NewNetworkManager(ctx, s, fs.manager, parentNM) + fs.networkManager, err = NewNetworkManager(ctx, k6Metrics, s, fs.manager, parentNM) if err != nil { l.Debugf("NewFrameSession:NewNetworkManager", "sid:%v tid:%v err:%v", s.ID(), tid, err) @@ -304,11 +306,6 @@ func (fs *FrameSession) parseAndEmitWebVitalMetric(object string) error { return fmt.Errorf("metric not registered %q", wv.Name) } - metricRating, ok := fs.k6Metrics.WebVitals[k6ext.ConcatWebVitalNameRating(wv.Name, wv.Rating)] - if !ok { - return fmt.Errorf("metric not registered %q", k6ext.ConcatWebVitalNameRating(wv.Name, wv.Rating)) - } - value, err := wv.Value.Float64() if err != nil { return fmt.Errorf("value couldn't be parsed %q", wv.Value) @@ -320,19 +317,16 @@ func (fs *FrameSession) parseAndEmitWebVitalMetric(object string) error { tags = tags.With("url", wv.URL) } + tags = tags.With("rating", wv.Rating) + now := time.Now() - k6metrics.PushIfNotDone(fs.ctx, state.Samples, k6metrics.ConnectedSamples{ + k6metrics.PushIfNotDone(fs.vu.Context(), state.Samples, k6metrics.ConnectedSamples{ Samples: []k6metrics.Sample{ { TimeSeries: k6metrics.TimeSeries{Metric: metric, Tags: tags}, Value: value, Time: now, }, - { - TimeSeries: k6metrics.TimeSeries{Metric: metricRating, Tags: tags}, - Value: 1, - Time: now, - }, }, }) @@ -588,7 +582,8 @@ func (fs *FrameSession) navigateFrame(frame *Frame, url, referrer string) (strin func (fs *FrameSession) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) { l := fs.serializer. WithTime(event.Timestamp.Time()). - WithField("source", "browser-console-api") + WithField("source", "browser"). + WithField("browser_source", "console-api") /* accessing the state Group while not on the eventloop is racy if s := fs.vu.State(); s.Group.Path != "" { @@ -1076,7 +1071,7 @@ func (fs *FrameSession) updateViewport() error { // add an inset to viewport depending on the operating system. // this won't add an inset if we're running in headless mode. viewport.calculateInset( - fs.page.browserCtx.browser.launchOpts.Headless, + fs.page.browserCtx.browser.browserOpts.Headless, runtime.GOOS, ) action2 := browser.SetWindowBounds(fs.windowID, &browser.Bounds{ diff --git a/vendor/github.com/grafana/xk6-browser/common/hooks.go b/vendor/github.com/grafana/xk6-browser/common/hooks.go index abfa2200502..dfa2340c2c7 100644 --- a/vendor/github.com/grafana/xk6-browser/common/hooks.go +++ b/vendor/github.com/grafana/xk6-browser/common/hooks.go @@ -30,7 +30,7 @@ func applySlowMo(ctx context.Context) { } func defaultSlowMo(ctx context.Context) { - sm := GetLaunchOptions(ctx).SlowMo + sm := GetBrowserOptions(ctx).SlowMo if sm <= 0 { return } diff --git a/vendor/github.com/grafana/xk6-browser/common/js/injected_script.js b/vendor/github.com/grafana/xk6-browser/common/js/injected_script.js index 4ee98c260eb..63ede878f41 100644 --- a/vendor/github.com/grafana/xk6-browser/common/js/injected_script.js +++ b/vendor/github.com/grafana/xk6-browser/common/js/injected_script.js @@ -223,6 +223,10 @@ class InjectedScript { } result.push({ element, capture }); } + + // Explore the Shadow DOM recursively. + const shadowResults = this._exploreShadowDOM(root.element, selector, index, queryCache, capture); + result.push(...shadowResults); } return this._querySelectorRecursively( @@ -233,6 +237,26 @@ class InjectedScript { ); } + _exploreShadowDOM(root, selector, index, queryCache, capture) { + let result = []; + if (root.shadowRoot) { + const shadowRootResults = this._querySelectorRecursively( + [{ element: root.shadowRoot, capture }], + selector, + index, + queryCache + ); + result = result.concat(shadowRootResults); + } + + for (let i = 0; i < root.children.length; i++) { + const childElement = root.children[i]; + result = result.concat(this._exploreShadowDOM(childElement, selector, index, queryCache, capture)); + } + + return result; + } + // Make sure we target an appropriate node in the DOM before performing an action. _retarget(node, behavior) { let element = diff --git a/vendor/github.com/grafana/xk6-browser/common/network_manager.go b/vendor/github.com/grafana/xk6-browser/common/network_manager.go index 7ca44ebf51e..3f8897e92e0 100644 --- a/vendor/github.com/grafana/xk6-browser/common/network_manager.go +++ b/vendor/github.com/grafana/xk6-browser/common/network_manager.go @@ -2,6 +2,7 @@ package common import ( "context" + "errors" "fmt" "net" "net/url" @@ -34,14 +35,15 @@ var _ EventEmitter = &NetworkManager{} type NetworkManager struct { BaseEventEmitter - ctx context.Context - logger *log.Logger - session session - parent *NetworkManager - frameManager *FrameManager - credentials *Credentials - resolver k6netext.Resolver - vu k6modules.VU + ctx context.Context + logger *log.Logger + session session + parent *NetworkManager + frameManager *FrameManager + credentials *Credentials + resolver k6netext.Resolver + vu k6modules.VU + customMetrics *k6ext.CustomMetrics // TODO: manage inflight requests separately (move them between the two maps // as they transition from inflight -> completed) @@ -59,7 +61,7 @@ type NetworkManager struct { // NewNetworkManager creates a new network manager. func NewNetworkManager( - ctx context.Context, s session, fm *FrameManager, parent *NetworkManager, + ctx context.Context, customMetrics *k6ext.CustomMetrics, s session, fm *FrameManager, parent *NetworkManager, ) (*NetworkManager, error) { vu := k6ext.GetVU(ctx) state := vu.State() @@ -80,6 +82,7 @@ func NewNetworkManager( frameManager: fm, resolver: resolver, vu: vu, + customMetrics: customMetrics, reqIDToRequest: make(map[network.RequestID]*Request), attemptedAuth: make(map[fetch.RequestID]bool), extraHTTPHeaders: make(map[string]string), @@ -153,10 +156,10 @@ func (m *NetworkManager) emitRequestMetrics(req *Request) { tags = tags.With("url", req.URL()) } - k6metrics.PushIfNotDone(m.ctx, state.Samples, k6metrics.ConnectedSamples{ + k6metrics.PushIfNotDone(m.vu.Context(), state.Samples, k6metrics.ConnectedSamples{ Samples: []k6metrics.Sample{ { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.DataSent, Tags: tags}, + TimeSeries: k6metrics.TimeSeries{Metric: m.customMetrics.BrowserDataSent, Tags: tags}, Value: float64(req.Size().Total()), Time: req.wallTime, }, @@ -176,6 +179,7 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { fromCache, fromPreCache, fromSvcWrk bool url = req.url.String() wallTime = time.Now() + failed float64 ) if resp != nil { status = resp.status @@ -187,6 +191,11 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { fromSvcWrk = resp.fromServiceWorker wallTime = resp.wallTime url = resp.url + // Assuming that a failure is when status + // is not between 200 and 399 (inclusive). + if status < 200 || status > 399 { + failed = 1 + } } else { m.logger.Debugf("NetworkManager:emitResponseMetrics", "response is nil url:%s method:%s", req.url, req.method) @@ -213,20 +222,15 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { tags = tags.With("from_prefetch_cache", strconv.FormatBool(fromPreCache)) tags = tags.With("from_service_worker", strconv.FormatBool(fromSvcWrk)) - k6metrics.PushIfNotDone(m.ctx, state.Samples, k6metrics.ConnectedSamples{ + k6metrics.PushIfNotDone(m.vu.Context(), state.Samples, k6metrics.ConnectedSamples{ Samples: []k6metrics.Sample{ { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqs, Tags: tags}, - Value: 1, - Time: wallTime, - }, - { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqDuration, Tags: tags}, + TimeSeries: k6metrics.TimeSeries{Metric: m.customMetrics.BrowserHTTPReqDuration, Tags: tags}, Value: k6metrics.D(wallTime.Sub(req.wallTime)), Time: wallTime, }, { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.DataReceived, Tags: tags}, + TimeSeries: k6metrics.TimeSeries{Metric: m.customMetrics.BrowserDataReceived, Tags: tags}, Value: float64(bodySize), Time: wallTime, }, @@ -234,26 +238,11 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { }) if resp != nil && resp.timing != nil { - k6metrics.PushIfNotDone(m.ctx, state.Samples, k6metrics.ConnectedSamples{ + k6metrics.PushIfNotDone(m.vu.Context(), state.Samples, k6metrics.ConnectedSamples{ Samples: []k6metrics.Sample{ { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqConnecting, Tags: tags}, - Value: k6metrics.D(time.Duration(resp.timing.ConnectEnd-resp.timing.ConnectStart) * time.Millisecond), - Time: wallTime, - }, - { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqTLSHandshaking, Tags: tags}, - Value: k6metrics.D(time.Duration(resp.timing.SslEnd-resp.timing.SslStart) * time.Millisecond), - Time: wallTime, - }, - { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqSending, Tags: tags}, - Value: k6metrics.D(time.Duration(resp.timing.SendEnd-resp.timing.SendStart) * time.Millisecond), - Time: wallTime, - }, - { - TimeSeries: k6metrics.TimeSeries{Metric: state.BuiltinMetrics.HTTPReqReceiving, Tags: tags}, - Value: k6metrics.D(time.Duration(resp.timing.ReceiveHeadersEnd-resp.timing.SendEnd) * time.Millisecond), + TimeSeries: k6metrics.TimeSeries{Metric: m.customMetrics.BrowserHTTPReqFailed, Tags: tags}, + Value: failed, Time: wallTime, }, }, @@ -441,7 +430,7 @@ func (m *NetworkManager) onRequest(event *network.EventRequestWillBeSent, interc m.frameManager.requestStarted(req) } -func (m *NetworkManager) onRequestPaused(event *fetch.EventRequestPaused) { +func (m *NetworkManager) onRequestPaused(event *fetch.EventRequestPaused) { //nolint:funlen m.logger.Debugf("NetworkManager:onRequestPaused", "sid:%s url:%v", m.session.ID(), event.Request.URL) defer m.logger.Debugf("NetworkManager:onRequestPaused:return", @@ -453,18 +442,31 @@ func (m *NetworkManager) onRequestPaused(event *fetch.EventRequestPaused) { if failErr != nil { action := fetch.FailRequest(event.RequestID, network.ErrorReasonBlockedByClient) if err := action.Do(cdp.WithExecutor(m.ctx, m.session)); err != nil { - m.logger.Errorf("NetworkManager:onRequestPaused", - "interrupting request: %s", err) - } else { - m.logger.Warnf("NetworkManager:onRequestPaused", - "request %s %s was interrupted: %s", event.Request.Method, event.Request.URL, failErr) + // 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:onRequestPaused", "context canceled interrupting request") + } else { + m.logger.Errorf("NetworkManager:onRequestPaused", "interrupting request: %s", err) + } return } + m.logger.Warnf("NetworkManager:onRequestPaused", + "request %s %s was interrupted: %s", event.Request.Method, event.Request.URL, failErr) + + return } action := fetch.ContinueRequest(event.RequestID) if err := action.Do(cdp.WithExecutor(m.ctx, m.session)); err != nil { - m.logger.Errorf("NetworkManager:onRequestPaused", - "continuing request: %s", err) + // Avoid logging as error when context is canceled. + // Most probably this happens when trying to continue 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:onRequestPaused", "context canceled continuing request") + return + } + m.logger.Errorf("NetworkManager:onRequestPaused", "continuing request: %s", err) } }() diff --git a/vendor/github.com/grafana/xk6-browser/common/options.go b/vendor/github.com/grafana/xk6-browser/common/options.go deleted file mode 100644 index 9d4e5399cc3..00000000000 --- a/vendor/github.com/grafana/xk6-browser/common/options.go +++ /dev/null @@ -1,60 +0,0 @@ -package common - -import ( - "fmt" - "reflect" - "time" - - "github.com/dop251/goja" - - "go.k6.io/k6/lib/types" -) - -func parseBoolOpt(key string, val goja.Value) (b bool, err error) { - if val.ExportType().Kind() != reflect.Bool { - return false, fmt.Errorf("%s should be a boolean", key) - } - b, _ = val.Export().(bool) - return b, nil -} - -func parseStrOpt(key string, val goja.Value) (s string, err error) { - if val.ExportType().Kind() != reflect.String { - return "", fmt.Errorf("%s should be a string", key) - } - return val.String(), nil -} - -func parseTimeOpt(key string, val goja.Value) (t time.Duration, err error) { - if t, err = types.GetDurationValue(val.String()); err != nil { - return time.Duration(0), fmt.Errorf("%s should be a time duration value: %w", key, err) - } - return -} - -// exportOpt exports src to dst and dynamically returns an error -// depending on the type if an error occurs. Panics if dst is not -// a pointer and not points to a map, struct, or slice. -func exportOpt[T any](rt *goja.Runtime, key string, src goja.Value, dst T) error { - typ := reflect.TypeOf(dst) - if typ.Kind() != reflect.Pointer { - panic("dst should be a pointer") - } - kind := typ.Elem().Kind() - s, ok := map[reflect.Kind]string{ - reflect.Map: "a map", - reflect.Struct: "an object", - reflect.Slice: "an array of", - }[kind] - if !ok { - panic("dst should be one of: map, struct, slice") - } - if err := rt.ExportTo(src, dst); err != nil { - if kind == reflect.Slice { - s += fmt.Sprintf(" %ss", typ.Elem().Elem()) - } - return fmt.Errorf("%s should be %s: %w", key, s, err) - } - - return nil -} diff --git a/vendor/github.com/grafana/xk6-browser/common/page.go b/vendor/github.com/grafana/xk6-browser/common/page.go index 4f888c66877..40df9f0d404 100644 --- a/vendor/github.com/grafana/xk6-browser/common/page.go +++ b/vendor/github.com/grafana/xk6-browser/common/page.go @@ -425,13 +425,22 @@ func (p *Page) Click(selector string, opts goja.Value) error { func (p *Page) Close(opts goja.Value) error { p.logger.Debugf("Page:Close", "sid:%v", p.sessionID()) + // forcing the pagehide event to trigger web vitals metrics. + v := p.vu.Runtime().ToValue(`() => window.dispatchEvent(new Event('pagehide'))`) + ctx, cancel := context.WithTimeout(p.ctx, p.defaultTimeout()) + defer cancel() + _, err := p.MainFrame().EvaluateWithContext(ctx, v) + if err != nil { + p.logger.Warnf("Page:Close", "failed to hide page: %v", err) + } + add := runtime.RemoveBinding(webVitalBinding) if err := add.Do(cdp.WithExecutor(p.ctx, p.session)); err != nil { return fmt.Errorf("internal error while removing binding from page: %w", err) } action := target.CloseTarget(p.targetID) - err := action.Do(cdp.WithExecutor(p.ctx, p.session)) + err = action.Do(cdp.WithExecutor(p.ctx, p.session)) if err != nil { // When a close target command is sent to the browser via CDP, // the browser will start to cleanup and the first thing it @@ -762,8 +771,8 @@ func (p *Page) Reload(opts goja.Value) api.Response { lifecycleEvtCh, lifecycleEvtCancel := createWaitForEventPredicateHandler( timeoutCtx, p.frameManager.MainFrame(), []string{EventFrameAddLifecycle}, func(data any) bool { - if le, ok := data.(LifecycleEvent); ok { - return le == parsedOpts.WaitUntil + if le, ok := data.(FrameLifecycleEvent); ok { + return le.Event == parsedOpts.WaitUntil } return false }) diff --git a/vendor/github.com/grafana/xk6-browser/common/response.go b/vendor/github.com/grafana/xk6-browser/common/response.go index c02ebc7abfe..13b19e4d803 100644 --- a/vendor/github.com/grafana/xk6-browser/common/response.go +++ b/vendor/github.com/grafana/xk6-browser/common/response.go @@ -179,7 +179,7 @@ func (r *Response) bodySize() int64 { } if err := r.fetchBody(); err != nil { - r.logger.Warnf("Response:bodySize:fetchBody", + r.logger.Debugf("Response:bodySize:fetchBody", "url:%s method:%s err:%s", r.url, r.request.method, err) } diff --git a/vendor/github.com/grafana/xk6-browser/common/session.go b/vendor/github.com/grafana/xk6-browser/common/session.go index 8c04272563c..16417ebaefe 100644 --- a/vendor/github.com/grafana/xk6-browser/common/session.go +++ b/vendor/github.com/grafana/xk6-browser/common/session.go @@ -3,7 +3,6 @@ package common import ( "context" "errors" - "sync/atomic" "github.com/chromedp/cdproto" "github.com/chromedp/cdproto/cdp" @@ -24,7 +23,7 @@ type Session struct { conn *Connection id target.SessionID targetID target.ID - msgID int64 + msgIDGen msgIDGenerator readCh chan *cdproto.Message done chan struct{} closed bool @@ -35,7 +34,7 @@ type Session struct { // NewSession creates a new session. func NewSession( - ctx context.Context, conn *Connection, id target.SessionID, tid target.ID, logger *log.Logger, + ctx context.Context, conn *Connection, id target.SessionID, tid target.ID, logger *log.Logger, msgIDGen msgIDGenerator, ) *Session { s := Session{ BaseEventEmitter: NewBaseEventEmitter(ctx), @@ -44,6 +43,7 @@ func NewSession( targetID: tid, readCh: make(chan *cdproto.Message), done: make(chan struct{}), + msgIDGen: msgIDGen, logger: logger, } @@ -118,7 +118,7 @@ func (s *Session) Execute(ctx context.Context, method string, params easyjson.Ma return ErrTargetCrashed } - id := atomic.AddInt64(&s.msgID, 1) + id := s.msgIDGen.newID() // Setup event handler used to block for response to message being sent. ch := make(chan *cdproto.Message, 1) @@ -186,7 +186,7 @@ func (s *Session) ExecuteWithoutExpectationOnReply(ctx context.Context, method s } } msg := &cdproto.Message{ - ID: atomic.AddInt64(&s.msgID, 1), + ID: s.msgIDGen.newID(), // We use different sessions to send messages to "targets" // (browser, page, frame etc.) in CDP. // diff --git a/vendor/github.com/grafana/xk6-browser/common/types.go b/vendor/github.com/grafana/xk6-browser/common/types.go index a2655cb2e1e..1bbf2d01d21 100644 --- a/vendor/github.com/grafana/xk6-browser/common/types.go +++ b/vendor/github.com/grafana/xk6-browser/common/types.go @@ -15,6 +15,9 @@ import ( "github.com/dop251/goja" ) +// BlankPage represents a blank page. +const BlankPage = "about:blank" + // ColorScheme represents a browser color scheme. type ColorScheme string @@ -217,6 +220,15 @@ func (f *ImageFormat) UnmarshalJSON(b []byte) error { return nil } +// FrameLifecycleEvent is emitted when a frame lifecycle event occurs. +type FrameLifecycleEvent struct { + // URL is the URL of the frame that emitted the event. + URL string + + // Event is the lifecycle event that occurred. + Event LifecycleEvent +} + type LifecycleEvent int const ( diff --git a/vendor/github.com/grafana/xk6-browser/env/env.go b/vendor/github.com/grafana/xk6-browser/env/env.go new file mode 100644 index 00000000000..4029ca122e0 --- /dev/null +++ b/vendor/github.com/grafana/xk6-browser/env/env.go @@ -0,0 +1,84 @@ +// Package env provides types to interact with environment setup. +package env + +import "os" + +// Execution specific. +const ( + // InstanceScenarios is an environment variable that can be used to + // define the extra scenarios details to use when running remotely. + InstanceScenarios = "K6_INSTANCE_SCENARIOS" + + // WebSocketURLs is an environment variable that can be used to + // define the WS URLs to connect to when running remotely. + WebSocketURLs = "K6_BROWSER_WS_URL" + + // BrowserArguments is an environment variable that can be used to + // pass extra arguments to the browser process. + BrowserArguments = "K6_BROWSER_ARGS" + + // BrowserExecutablePath is an environment variable that can be used + // to define the path to the browser to execute. + BrowserExecutablePath = "K6_BROWSER_EXECUTABLE_PATH" + + // BrowserEnableDebugging is an environment variable that can be used to + // define if the browser should be launched with debugging enabled. + BrowserEnableDebugging = "K6_BROWSER_DEBUG" + + // BrowserHeadless is an environment variable that can be used to + // define if the browser should be launched in headless mode. + BrowserHeadless = "K6_BROWSER_HEADLESS" + + // BrowserIgnoreDefaultArgs is an environment variable that can be + // used to define if the browser should ignore default arguments. + BrowserIgnoreDefaultArgs = "K6_BROWSER_IGNORE_DEFAULT_ARGS" + + // BrowserGlobalTimeout is an environment variable that can be used + // to set the global timeout for the browser. + BrowserGlobalTimeout = "K6_BROWSER_TIMEOUT" +) + +// Logging and debugging. +const ( + // EnableProfiling is an environment variable that can be used to + // enable profiling for the browser. It will start up a debugging + // server on ProfilingServerAddr. + EnableProfiling = "K6_BROWSER_ENABLE_PPROF" + + // ProfilingServerAddr is the address of the profiling server. + ProfilingServerAddr = "localhost:6060" + + // LogCaller is an environment variable that can be used to enable + // the caller function information in the browser logs. + LogCaller = "K6_BROWSER_LOG_CALLER" + + // LogLevel is an environment variable that can be used to set the + // log level for the browser logs. + LogLevel = "K6_BROWSER_LOG" + + // LogCategoryFilter is an environment variable that can be used to + // filter the browser logs based on their category. It supports + // regular expressions. + LogCategoryFilter = "K6_BROWSER_LOG_CATEGORY_FILTER" +) + +// LookupFunc defines a function to look up a key from the environment. +type LookupFunc func(key string) (string, bool) + +// EmptyLookup is a LookupFunc that always returns "" and false. +func EmptyLookup(key string) (string, bool) { return "", false } + +// Lookup is a LookupFunc that uses os.LookupEnv. +func Lookup(key string) (string, bool) { return os.LookupEnv(key) } + +// ConstLookup is a LookupFunc that always returns the given value and true +// if the key matches the given key. Otherwise it returns EmptyLookup +// behaviour. Useful for testing. +func ConstLookup(k, v string) LookupFunc { + return func(key string) (string, bool) { + if key == k { + return v, true + } + return EmptyLookup(key) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/context.go b/vendor/github.com/grafana/xk6-browser/k6ext/context.go index 9c1de769823..8f763bcdc29 100644 --- a/vendor/github.com/grafana/xk6-browser/k6ext/context.go +++ b/vendor/github.com/grafana/xk6-browser/k6ext/context.go @@ -4,6 +4,7 @@ import ( "context" k6modules "go.k6.io/k6/js/modules" + k6lib "go.k6.io/k6/lib" "github.com/dop251/goja" ) @@ -51,3 +52,25 @@ func GetCustomMetrics(ctx context.Context) *CustomMetrics { func Runtime(ctx context.Context) *goja.Runtime { return GetVU(ctx).Runtime() } + +// GetScenarioName returns the scenario name associated with the given context. +func GetScenarioName(ctx context.Context) string { + ss := k6lib.GetScenarioState(ctx) + if ss == nil { + return "" + } + return ss.Name +} + +// GetScenarioOpts returns the browser options and environment variables associated +// with the given context. +func GetScenarioOpts(ctx context.Context, vu k6modules.VU) map[string]any { + scenario := GetScenarioName(ctx) + if scenario == "" { + return nil + } + if so := vu.State().Options.Scenarios[scenario].GetScenarioOptions(); so != nil { + return so.Browser + } + return nil +} diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/env.go b/vendor/github.com/grafana/xk6-browser/k6ext/env.go deleted file mode 100644 index cf0fd468bd0..00000000000 --- a/vendor/github.com/grafana/xk6-browser/k6ext/env.go +++ /dev/null @@ -1,40 +0,0 @@ -package k6ext - -import ( - "crypto/rand" - "math/big" - "strings" -) - -type envLookupper func(key string) (string, bool) - -// IsRemoteBrowser returns true and the corresponding CDP -// WS URL if this one is set through the K6_BROWSER_WS_URL -// environment variable. Otherwise returns false. -// If K6_BROWSER_WS_URL is set as a comma separated list of -// URLs, this method returns a randomly chosen URL from the list -// so connections are done in a round-robin fashion for all the -// entries in the list. -func IsRemoteBrowser(envLookup envLookupper) (wsURL string, isRemote bool) { - wsURL, isRemote = envLookup("K6_BROWSER_WS_URL") - if !isRemote { - return "", false - } - if !strings.ContainsRune(wsURL, ',') { - return wsURL, isRemote - } - - // If last parts element is a void string, - // because WS URL contained an ending comma, - // remove it - parts := strings.Split(wsURL, ",") - if parts[len(parts)-1] == "" { - parts = parts[:len(parts)-1] - } - - // Choose a random WS URL from the provided list - i, _ := rand.Int(rand.Reader, big.NewInt(int64(len(parts)))) - wsURL = parts[i.Int64()] - - return wsURL, isRemote -} diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/metrics.go b/vendor/github.com/grafana/xk6-browser/k6ext/metrics.go index d727100918c..a2991b5586f 100644 --- a/vendor/github.com/grafana/xk6-browser/k6ext/metrics.go +++ b/vendor/github.com/grafana/xk6-browser/k6ext/metrics.go @@ -1,8 +1,6 @@ package k6ext import ( - "fmt" - k6metrics "go.k6.io/k6/metrics" ) @@ -13,23 +11,40 @@ const ( webVitalCLS = "CLS" webVitalINP = "INP" webVitalFCP = "FCP" + + fidName = "browser_web_vital_fid" + ttfbName = "browser_web_vital_ttfb" + lcpName = "browser_web_vital_lcp" + clsName = "browser_web_vital_cls" + inpName = "browser_web_vital_inp" + fcpName = "browser_web_vital_fcp" + + browserDataSentName = "browser_data_sent" + browserDataReceivedName = "browser_data_received" + browserHTTPReqDurationName = "browser_http_req_duration" + browserHTTPReqFailedName = "browser_http_req_failed" ) // CustomMetrics are the custom k6 metrics used by xk6-browser. type CustomMetrics struct { WebVitals map[string]*k6metrics.Metric + + BrowserDataSent *k6metrics.Metric + BrowserDataReceived *k6metrics.Metric + BrowserHTTPReqDuration *k6metrics.Metric + BrowserHTTPReqFailed *k6metrics.Metric } // RegisterCustomMetrics creates and registers our custom metrics with the k6 // VU Registry and returns our internal struct pointer. func RegisterCustomMetrics(registry *k6metrics.Registry) *CustomMetrics { wvs := map[string]string{ - webVitalFID: "webvital_first_input_delay", - webVitalTTFB: "webvital_time_to_first_byte", - webVitalLCP: "webvital_largest_content_paint", - webVitalCLS: "webvital_cumulative_layout_shift", - webVitalINP: "webvital_interaction_to_next_paint", - webVitalFCP: "webvital_first_contentful_paint", + webVitalFID: fidName, // first input delay + webVitalTTFB: ttfbName, // time to first byte + webVitalLCP: lcpName, // largest content paint + webVitalCLS: clsName, // cumulative layout shift + webVitalINP: inpName, // interaction to next paint + webVitalFCP: fcpName, // first contentful paint } webVitals := make(map[string]*k6metrics.Metric) @@ -42,24 +57,14 @@ func RegisterCustomMetrics(registry *k6metrics.Registry) *CustomMetrics { } webVitals[k] = registry.MustNewMetric(v, k6metrics.Trend, t) - - webVitals[ConcatWebVitalNameRating(k, "good")] = registry.MustNewMetric( - v+"_good", k6metrics.Counter) - webVitals[ConcatWebVitalNameRating(k, "needs-improvement")] = registry.MustNewMetric( - v+"_needs_improvement", k6metrics.Counter) - webVitals[ConcatWebVitalNameRating(k, "poor")] = registry.MustNewMetric( - v+"_poor", k6metrics.Counter) } + //nolint:lll return &CustomMetrics{ - WebVitals: webVitals, + WebVitals: webVitals, + BrowserDataSent: registry.MustNewMetric(browserDataSentName, k6metrics.Counter, k6metrics.Data), + BrowserDataReceived: registry.MustNewMetric(browserDataReceivedName, k6metrics.Counter, k6metrics.Data), + BrowserHTTPReqDuration: registry.MustNewMetric(browserHTTPReqDurationName, k6metrics.Trend, k6metrics.Time), + BrowserHTTPReqFailed: registry.MustNewMetric(browserHTTPReqFailedName, k6metrics.Rate), } } - -// ConcatWebVitalNameRating can be used -// to create the correct metric key name -// to retrieve the corresponding metric -// from the registry. -func ConcatWebVitalNameRating(name, rating string) string { - return fmt.Sprintf("%s:%s", name, rating) -} diff --git a/vendor/github.com/grafana/xk6-browser/log/logger.go b/vendor/github.com/grafana/xk6-browser/log/logger.go index 374e26c7266..dc17684b1a3 100644 --- a/vendor/github.com/grafana/xk6-browser/log/logger.go +++ b/vendor/github.com/grafana/xk6-browser/log/logger.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "regexp" "runtime" - "strconv" "strings" "sync" "time" @@ -98,9 +97,9 @@ func (l *Logger) Logf(level logrus.Level, category string, msg string, args ...a return } fields := logrus.Fields{ - "category": category, - "elapsed": fmt.Sprintf("%d ms", elapsed), - "goroutine": goRoutineID(), + "source": "browser", + "category": category, + "elapsed": fmt.Sprintf("%d ms", elapsed), } if l.iterID != "" && l.GetLevel() > logrus.InfoLevel { fields["iteration_id"] = l.iterID @@ -161,6 +160,7 @@ func (l *Logger) ConsoleLogFormatterSerializer() *Logger { Out: l.Out, Level: l.Level, Formatter: &consoleLogFormatter{l.Formatter}, + Hooks: l.Hooks, }, } } @@ -176,17 +176,6 @@ func (l *Logger) SetCategoryFilter(filter string) (err error) { return nil } -func goRoutineID() int { - var buf [64]byte - n := runtime.Stack(buf[:], false) - idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0] - id, err := strconv.Atoi(idField) - if err != nil { - panic(fmt.Sprintf("internal error while getting goroutine ID: %v", err)) - } - return id -} - type consoleLogFormatter struct { logrus.Formatter } diff --git a/vendor/modules.txt b/vendor/modules.txt index 32d5b1c3402..5ae9d4a175c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -150,13 +150,14 @@ github.com/google/uuid # github.com/gorilla/websocket v1.5.0 ## explicit; go 1.12 github.com/gorilla/websocket -# github.com/grafana/xk6-browser v0.10.0 +# github.com/grafana/xk6-browser v1.0.2 ## explicit; go 1.19 github.com/grafana/xk6-browser/api github.com/grafana/xk6-browser/browser github.com/grafana/xk6-browser/chromium github.com/grafana/xk6-browser/common github.com/grafana/xk6-browser/common/js +github.com/grafana/xk6-browser/env github.com/grafana/xk6-browser/k6error github.com/grafana/xk6-browser/k6ext github.com/grafana/xk6-browser/keyboardlayout