Skip to content

Commit

Permalink
Merge pull request #800 from grafana/feat/17-browsertype-connect
Browse files Browse the repository at this point in the history
Implement `BrowserType.connect`.
  • Loading branch information
ka3de committed Mar 2, 2023
2 parents 88606db + 1bbf855 commit 69f3c57
Show file tree
Hide file tree
Showing 11 changed files with 385 additions and 149 deletions.
2 changes: 1 addition & 1 deletion api/browser_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

// BrowserType is the public interface of a CDP browser client.
type BrowserType interface {
Connect(opts goja.Value)
Connect(wsEndpoint string, opts goja.Value) Browser
ExecutablePath() string
Launch(opts goja.Value) (_ Browser, browserProcessID int)
LaunchPersistentContext(userDataDir string, opts goja.Value) Browser
Expand Down
6 changes: 5 additions & 1 deletion browser/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,11 @@ func mapBrowser(vu moduleVU, b api.Browser) mapping {
func mapBrowserType(vu moduleVU, bt api.BrowserType) mapping {
rt := vu.Runtime()
return mapping{
"connect": bt.Connect,
"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,
Expand Down
196 changes: 129 additions & 67 deletions chromium/browser_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ type BrowserType struct {
vu k6modules.VU
hooks *common.Hooks
k6Metrics *k6ext.CustomMetrics
execPath string // path to the Chromium executable
storage *storage.Dir // stores temporary data for the extension and user
execPath string // path to the Chromium executable
randSrc *rand.Rand
logger *log.Logger
}

// NewBrowserType registers our custom k6 metrics, creates method mappings on
Expand All @@ -54,57 +52,42 @@ func NewBrowserType(vu k6modules.VU) api.BrowserType {
vu: vu,
hooks: common.NewHooks(),
k6Metrics: k6m,
storage: &storage.Dir{},
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())), //nolint: gosec
}

return &b
}

// Connect attaches k6 browser to an existing browser instance.
func (b *BrowserType) Connect(opts goja.Value) {
rt := b.vu.Runtime()
k6common.Throw(rt, errors.New("BrowserType.connect() has not been implemented yet"))
}
func (b *BrowserType) init(
opts goja.Value, isRemoteBrowser bool,
) (context.Context, *common.LaunchOptions, *log.Logger, error) {
ctx := b.initContext()

// ExecutablePath returns the path where the extension expects to find the browser executable.
func (b *BrowserType) ExecutablePath() (execPath string) {
if b.execPath != "" {
return b.execPath
logger, err := makeLogger(ctx)
if err != nil {
return nil, nil, nil, fmt.Errorf("error setting up logger: %w", err)
}
defer func() {
b.execPath = execPath
}()

for _, path := range [...]string{
// Unix-like
"headless_shell",
"headless-shell",
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"/usr/bin/google-chrome",
var launchOpts *common.LaunchOptions
if isRemoteBrowser {
launchOpts = common.NewRemoteBrowserLaunchOptions()
} else {
launchOpts = common.NewLaunchOptions()
}

// 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`),
if err = launchOpts.Parse(ctx, logger, opts); err != nil {
return nil, nil, nil, fmt.Errorf("error parsing launch options: %w", err)
}
ctx = common.WithLaunchOptions(ctx, launchOpts)

// 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 _, err := exec.LookPath(path); err == nil {
return path
}
if err := logger.SetCategoryFilter(launchOpts.LogCategoryFilter); err != nil {
return nil, nil, nil, fmt.Errorf("error setting category filter: %w", err)
}
if launchOpts.Debug {
_ = logger.SetLevel("debug")
}

return ""
return ctx, launchOpts, logger, nil
}

func (b *BrowserType) initContext() context.Context {
Expand All @@ -115,22 +98,70 @@ func (b *BrowserType) initContext() context.Context {
return ctx
}

// 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)
if err != nil {
k6ext.Panic(ctx, "initializing browser type: %w", err)
}

bp, err := b.connect(ctx, wsEndpoint, launchOpts, logger)
if err != nil {
err = &k6ext.UserFriendlyError{
Err: err,
Timeout: launchOpts.Timeout,
}
k6ext.Panic(ctx, "%w", err)
}

return bp
}

func (b *BrowserType) connect(
ctx context.Context, wsURL string, opts *common.LaunchOptions, logger *log.Logger,
) (*common.Browser, error) {
browserProc, err := b.link(ctx, wsURL, opts, logger)
if browserProc == nil {
return nil, fmt.Errorf("connecting to browser: %w", err)
}

// If this context is cancelled we'll initiate an extension wide
// cancellation and shutdown.
browserCtx, browserCtxCancel := context.WithCancel(ctx)
b.Ctx = browserCtx
browser, err := common.NewBrowser(
browserCtx, browserCtxCancel, browserProc, opts, logger,
)
if err != nil {
return nil, fmt.Errorf("connecting to browser: %w", err)
}

return browser, nil
}

func (b *BrowserType) link(
ctx context.Context, wsURL string,
opts *common.LaunchOptions, logger *log.Logger,
) (*common.BrowserProcess, error) {
bProcCtx, bProcCtxCancel := context.WithTimeout(ctx, opts.Timeout)
p, err := common.NewRemoteBrowserProcess(bProcCtx, wsURL, bProcCtxCancel, logger)
if err != nil {
bProcCtxCancel()
return nil, err //nolint:wrapcheck
}

return p, nil
}

// 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 := b.initContext()

var err error
if b.logger, err = makeLogger(ctx); err != nil {
k6ext.Panic(ctx, "setting up logger: %w", err)
}
launchOpts := common.NewLaunchOptions(k6ext.OnCloud())
if err := launchOpts.Parse(ctx, b.logger, opts); err != nil {
k6ext.Panic(ctx, "parsing launch options: %w", err)
ctx, launchOpts, logger, err := b.init(opts, false)
if err != nil {
k6ext.Panic(ctx, "initializing browser type: %w", err)
}
ctx = common.WithLaunchOptions(ctx, launchOpts)

bp, pid, err := b.launch(ctx, launchOpts)
bp, pid, err := b.launch(ctx, launchOpts, logger)
if err != nil {
err = &k6ext.UserFriendlyError{
Err: err,
Expand All @@ -143,15 +174,8 @@ func (b *BrowserType) Launch(opts goja.Value) (_ api.Browser, browserProcessID i
}

func (b *BrowserType) launch(
ctx context.Context, opts *common.LaunchOptions,
ctx context.Context, opts *common.LaunchOptions, logger *log.Logger,
) (_ *common.Browser, pid int, _ error) {
if err := b.logger.SetCategoryFilter(opts.LogCategoryFilter); err != nil {
return nil, 0, fmt.Errorf("%w", err)
}
if opts.Debug {
_ = b.logger.SetLevel("debug")
}

envs := make([]string, 0, len(opts.Env))
for k, v := range opts.Env {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
Expand All @@ -160,7 +184,7 @@ func (b *BrowserType) launch(
if err != nil {
return nil, 0, fmt.Errorf("%w", err)
}
dataDir := b.storage
dataDir := &storage.Dir{}
if err := dataDir.Make("", flags["user-data-dir"]); err != nil {
return nil, 0, fmt.Errorf("%w", err)
}
Expand All @@ -169,7 +193,7 @@ func (b *BrowserType) launch(
go func(c context.Context) {
defer func() {
if err := dataDir.Cleanup(); err != nil {
b.logger.Errorf("BrowserType:Launch", "cleaning up the user data directory: %v", err)
logger.Errorf("BrowserType:Launch", "cleaning up the user data directory: %v", err)
}
}()
// There's a small chance that this might be called
Expand All @@ -180,19 +204,17 @@ func (b *BrowserType) launch(
<-c.Done()
}(ctx)

browserProc, err := b.allocate(ctx, opts, flags, envs, dataDir, b.logger)
browserProc, err := b.allocate(ctx, opts, flags, envs, dataDir, logger)
if browserProc == nil {
return nil, 0, fmt.Errorf("launching browser: %w", err)
}

browserProc.AttachLogger(b.logger)

// If this context is cancelled we'll initiate an extension wide
// cancellation and shutdown.
browserCtx, browserCtxCancel := context.WithCancel(ctx)
b.Ctx = browserCtx
browser, err := common.NewBrowser(browserCtx, browserCtxCancel,
browserProc, opts, b.logger)
browserProc, opts, logger)
if err != nil {
return nil, 0, fmt.Errorf("launching browser: %w", err)
}
Expand Down Expand Up @@ -235,7 +257,47 @@ func (b *BrowserType) allocate(
path = b.ExecutablePath()
}

return common.NewBrowserProcess(bProcCtx, path, args, env, dataDir, bProcCtxCancel, logger) //nolint: wrapcheck
return common.NewLocalBrowserProcess(bProcCtx, path, args, env, dataDir, bProcCtxCancel, logger) //nolint: wrapcheck
}

// ExecutablePath returns the path where the extension expects to find the browser executable.
func (b *BrowserType) ExecutablePath() (execPath string) {
if b.execPath != "" {
return b.execPath
}
defer func() {
b.execPath = execPath
}()

for _, path := range [...]string{
// Unix-like
"headless_shell",
"headless-shell",
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"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 _, err := exec.LookPath(path); err == nil {
return path
}
}

return ""
}

// parseArgs parses command-line arguments and returns them.
Expand Down
25 changes: 17 additions & 8 deletions common/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package common

import (
"context"
"errors"
"fmt"
"strings"
"sync"
Expand All @@ -23,8 +24,10 @@ import (
)

// Ensure Browser implements the EventEmitter and Browser interfaces.
var _ EventEmitter = &Browser{}
var _ api.Browser = &Browser{}
var (
_ EventEmitter = &Browser{}
_ api.Browser = &Browser{}
)

const (
BrowserStateOpen int64 = iota
Expand Down Expand Up @@ -392,7 +395,7 @@ func (b *Browser) newPageInContext(id cdp.BrowserContextID) (*Page, error) {
// Close shuts down the browser.
func (b *Browser) Close() {
defer func() {
if err := b.browserProc.userDataDir.Cleanup(); err != nil {
if err := b.browserProc.Cleanup(); err != nil {
b.logger.Errorf("Browser:Close", "cleaning up the user data directory: %v", err)
}
}()
Expand All @@ -408,11 +411,12 @@ func (b *Browser) Close() {
b.conn.IgnoreIOErrors()
b.browserProc.GracefulClose()

// Send the Browser.close CDP command, which triggers the browser process to
// exit.
action := cdpbrowser.Close()
if err := action.Do(cdp.WithExecutor(b.ctx, b.conn)); err != nil {
if _, ok := err.(*websocket.CloseError); !ok {
// 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 {
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)
}
}
Expand Down Expand Up @@ -524,3 +528,8 @@ func (b *Browser) Version() string {
}
return product[i+1:]
}

// WsURL returns the Websocket URL that the browser is listening on for CDP clients.
func (b *Browser) WsURL() string {
return b.browserProc.WsURL()
}

0 comments on commit 69f3c57

Please sign in to comment.