github.com/saucelabs/saucectl@v0.175.1/internal/http/webdriver.go (about)

     1  package http
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"time"
    12  
    13  	"github.com/saucelabs/saucectl/internal/iam"
    14  	"github.com/saucelabs/saucectl/internal/job"
    15  	"github.com/saucelabs/saucectl/internal/slice"
    16  	"github.com/saucelabs/saucectl/internal/version"
    17  )
    18  
    19  // Webdriver service
    20  type Webdriver struct {
    21  	HTTPClient  *http.Client
    22  	URL         string
    23  	Credentials iam.Credentials
    24  }
    25  
    26  // SessionRequest represents the webdriver session request.
    27  type SessionRequest struct {
    28  	Capabilities        Capabilities `json:"capabilities,omitempty"`
    29  	DesiredCapabilities MatchingCaps `json:"desiredCapabilities,omitempty"`
    30  }
    31  
    32  // Capabilities represents the webdriver capabilities.
    33  // https://www.w3.org/TR/webdriver/
    34  type Capabilities struct {
    35  	AlwaysMatch MatchingCaps `json:"alwaysMatch,omitempty"`
    36  }
    37  
    38  // MatchingCaps are specific attributes that together form the capabilities that are used to match a session.
    39  type MatchingCaps struct {
    40  	App               string    `json:"app,omitempty"`
    41  	TestApp           string    `json:"testApp,omitempty"`
    42  	OtherApps         []string  `json:"otherApps,omitempty"`
    43  	BrowserName       string    `json:"browserName,omitempty"`
    44  	BrowserVersion    string    `json:"browserVersion,omitempty"`
    45  	PlatformName      string    `json:"platformName,omitempty"`
    46  	SauceOptions      SauceOpts `json:"sauce:options,omitempty"`
    47  	PlatformVersion   string    `json:"platformVersion,omitempty"`
    48  	DeviceName        string    `json:"deviceName,omitempty"`
    49  	DeviceOrientation string    `json:"deviceOrientation,omitempty"`
    50  }
    51  
    52  // SauceOpts represents the Sauce Labs specific capabilities.
    53  type SauceOpts struct {
    54  	TestName         string   `json:"name,omitempty"`
    55  	Tags             []string `json:"tags,omitempty"`
    56  	BuildName        string   `json:"build,omitempty"`
    57  	Batch            Batch    `json:"_batch,omitempty"`
    58  	IdleTimeout      int      `json:"idleTimeout,omitempty"`
    59  	MaxDuration      int      `json:"maxDuration,omitempty"`
    60  	TunnelIdentifier string   `json:"tunnelIdentifier,omitempty"`
    61  	TunnelParent     string   `json:"parentTunnel,omitempty"` // note that 'parentTunnel` is backwards, because that's the way sauce likes it
    62  	ScreenResolution string   `json:"screen_resolution,omitempty"`
    63  	SauceCloudNode   string   `json:"_sauceCloudNode,omitempty"`
    64  	UserAgent        string   `json:"user_agent,omitempty"`
    65  	TimeZone         string   `json:"timeZone,omitempty"`
    66  	Visibility       string   `json:"public,omitempty"`
    67  }
    68  
    69  type env struct {
    70  	Name  string `json:"name,omitempty"`
    71  	Value string `json:"value,omitempty"`
    72  }
    73  
    74  // Batch represents capabilities for batch frameworks.
    75  type Batch struct {
    76  	Framework        string              `json:"framework,omitempty"`
    77  	FrameworkVersion string              `json:"frameworkVersion,omitempty"`
    78  	RunnerVersion    string              `json:"runnerVersion,omitempty"`
    79  	TestFile         string              `json:"testFile,omitempty"`
    80  	Args             []map[string]string `json:"args,omitempty"`
    81  	VideoFPS         int                 `json:"video_fps"`
    82  	Env              []env               `json:"env,omitempty"`
    83  }
    84  
    85  // sessionStartResponse represents the response body for starting a session.
    86  type sessionStartResponse struct {
    87  	Status    int    `json:"status,omitempty"`
    88  	SessionID string `json:"sessionId,omitempty"`
    89  	Value     struct {
    90  		Message string `json:"message,omitempty"`
    91  	} `json:"value,omitempty"`
    92  }
    93  
    94  func NewWebdriver(url string, creds iam.Credentials, timeout time.Duration) Webdriver {
    95  	return Webdriver{
    96  		HTTPClient: &http.Client{
    97  			Timeout: timeout,
    98  			Transport: &http.Transport{
    99  				Proxy: http.ProxyFromEnvironment,
   100  				// The server seems to terminate idle connections within 10 minutes,
   101  				// without any Keep-Alive information. We need to stay ahead of
   102  				// the server side disconnect.
   103  				IdleConnTimeout: 3 * time.Minute,
   104  			},
   105  			CheckRedirect: func(req *http.Request, via []*http.Request) error {
   106  				// Sauce can queue up Job start requests for up to 10 minutes and sends redirects in the meantime to
   107  				// keep the connection alive. A redirect is sent every 45 seconds.
   108  				// 10m / 45s requires a minimum of 14 redirects.
   109  				if len(via) >= 20 {
   110  					return errors.New("stopped after 20 redirects")
   111  				}
   112  
   113  				return nil
   114  			},
   115  		},
   116  		URL:         url,
   117  		Credentials: creds,
   118  	}
   119  }
   120  
   121  // StartJob creates a new job in Sauce Labs.
   122  func (c *Webdriver) StartJob(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
   123  	url := fmt.Sprintf("%s/wd/hub/session", c.URL)
   124  
   125  	caps := Capabilities{AlwaysMatch: MatchingCaps{
   126  		App:             opts.App,
   127  		TestApp:         opts.TestApp,
   128  		OtherApps:       opts.OtherApps,
   129  		BrowserName:     c.normalizeBrowser(opts.Framework, opts.BrowserName),
   130  		BrowserVersion:  opts.BrowserVersion,
   131  		PlatformName:    opts.PlatformName,
   132  		PlatformVersion: opts.PlatformVersion,
   133  		SauceOptions: SauceOpts{
   134  			UserAgent:        "saucectl/" + version.Version,
   135  			TunnelIdentifier: opts.Tunnel.ID,
   136  			TunnelParent:     opts.Tunnel.Parent,
   137  			ScreenResolution: opts.ScreenResolution,
   138  			SauceCloudNode:   opts.Experiments["_sauceCloudNode"],
   139  			TestName:         opts.Name,
   140  			BuildName:        opts.Build,
   141  			Tags:             opts.Tags,
   142  			Batch: Batch{
   143  				Framework:        opts.Framework,
   144  				FrameworkVersion: opts.FrameworkVersion,
   145  				RunnerVersion:    opts.RunnerVersion,
   146  				TestFile:         opts.Suite,
   147  				Args:             c.formatTestOptions(opts.TestOptions),
   148  				VideoFPS:         13, // 13 is the sweet spot to minimize frame drops
   149  				Env:              formatEnv(opts.Env),
   150  			},
   151  			IdleTimeout: 9999,
   152  			MaxDuration: 10800,
   153  			TimeZone:    opts.TimeZone,
   154  			Visibility:  opts.Visibility,
   155  		},
   156  		DeviceName:        opts.DeviceName,
   157  		DeviceOrientation: opts.DeviceOrientation,
   158  	}}
   159  
   160  	// Emulator/Simulator requests are allergic to W3C capabilities. Requests get routed to RDC. However, using legacy
   161  	// format alone is insufficient, we need both.
   162  	session := SessionRequest{
   163  		Capabilities:        caps,
   164  		DesiredCapabilities: caps.AlwaysMatch,
   165  	}
   166  
   167  	var b bytes.Buffer
   168  	err = json.NewEncoder(&b).Encode(session)
   169  	if err != nil {
   170  		return
   171  	}
   172  
   173  	req, err := NewRequestWithContext(ctx, http.MethodPost, url, &b)
   174  	if err != nil {
   175  		return
   176  	}
   177  	req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey)
   178  
   179  	resp, err := c.HTTPClient.Do(req)
   180  	if err != nil {
   181  		return
   182  	}
   183  	defer resp.Body.Close()
   184  	body, err := io.ReadAll(resp.Body)
   185  	if err != nil {
   186  		return
   187  	}
   188  
   189  	var sessionStart sessionStartResponse
   190  	if err = json.Unmarshal(body, &sessionStart); err != nil {
   191  		return "", false, fmt.Errorf("job start failed (%d): %s", resp.StatusCode, body)
   192  	}
   193  
   194  	if sessionStart.SessionID == "" {
   195  		err = fmt.Errorf("job start failed (%d): %s", resp.StatusCode, sessionStart.Value.Message)
   196  		return "", false, err
   197  	}
   198  
   199  	return sessionStart.SessionID, false, nil
   200  }
   201  
   202  func formatEnv(e map[string]string) []env {
   203  	var envs []env
   204  
   205  	for k, v := range e {
   206  		envs = append(envs, env{
   207  			Name:  k,
   208  			Value: v,
   209  		})
   210  	}
   211  	return envs
   212  }
   213  
   214  // formatTestOptions adapts option shape to match chef expectations
   215  func (c *Webdriver) formatTestOptions(options map[string]interface{}) []map[string]string {
   216  	var mappedOptions []map[string]string
   217  	for k, v := range options {
   218  		if v == nil {
   219  			continue
   220  		}
   221  
   222  		value := fmt.Sprintf("%v", v)
   223  
   224  		// class/notClass need special treatment, because we accept these as slices, but the backend wants
   225  		// a comma separated string.
   226  		if k == "class" || k == "notClass" {
   227  			value = slice.Join(v, ",")
   228  		}
   229  
   230  		if value == "" {
   231  			continue
   232  		}
   233  		mappedOptions = append(mappedOptions, map[string]string{
   234  			"name":  k,
   235  			"value": value,
   236  		})
   237  	}
   238  	return mappedOptions
   239  }
   240  
   241  // normalizeBrowser converts the user specified browsers into something Sauce Labs can understand better.
   242  func (c *Webdriver) normalizeBrowser(framework, browser string) string {
   243  	switch framework {
   244  	case "cypress":
   245  		switch browser {
   246  		case "chrome":
   247  			return "googlechrome"
   248  		case "webkit":
   249  			return "cypress-webkit"
   250  		}
   251  	case "testcafe":
   252  		switch browser {
   253  		case "chrome":
   254  			return "googlechrome"
   255  		}
   256  	case "playwright":
   257  		switch browser {
   258  		case "chrome":
   259  			return "googlechrome"
   260  		case "chromium":
   261  			return "playwright-chromium"
   262  		case "firefox":
   263  			return "playwright-firefox"
   264  		case "webkit":
   265  			return "playwright-webkit"
   266  		}
   267  	}
   268  	return browser
   269  }