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 }