github.com/bazelbuild/rules_webtesting@v0.2.0/go/webdriver/webdriver.go (about) 1 // Copyright 2016 Google Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package webdriver provides a simple and incomplete WebDriver client for use by web test launcher. 16 package webdriver 17 18 import ( 19 "bytes" 20 "context" 21 "encoding/base64" 22 "encoding/json" 23 "fmt" 24 "image" 25 "image/png" 26 "io" 27 "io/ioutil" 28 "log" 29 "net/http" 30 "net/url" 31 "path" 32 "strings" 33 "time" 34 35 "github.com/bazelbuild/rules_webtesting/go/errors" 36 "github.com/bazelbuild/rules_webtesting/go/healthreporter" 37 "github.com/bazelbuild/rules_webtesting/go/metadata/capabilities" 38 ) 39 40 const ( 41 compName = "Go WebDriver Client" 42 seleniumElementKey = "ELEMENT" 43 w3cElementKey = "element-6066-11e4-a52e-4f735466cecf" 44 ) 45 46 // WebDriver provides access to a running WebDriver session 47 type WebDriver interface { 48 healthreporter.HealthReporter 49 // ExecuteScript executes script inside the browser's current execution context. 50 ExecuteScript(ctx context.Context, script string, args []interface{}, value interface{}) error 51 // ExecuteScriptAsync executes script asynchronously inside the browser's current execution context. 52 ExecuteScriptAsync(ctx context.Context, script string, args []interface{}, value interface{}) error 53 // ExecuteScriptAsyncWithTimeout executes the script asynchronously, but sets the script timeout to timeout before, 54 // and attempts to restore it to its previous value after. 55 ExecuteScriptAsyncWithTimeout(ctx context.Context, timeout time.Duration, script string, args []interface{}, value interface{}) error 56 // Quit closes the WebDriver session. 57 Quit(context.Context) error 58 // CommandURL builds a fully resolved URL for the specified end-point. 59 CommandURL(endpoint ...string) (*url.URL, error) 60 // SetScriptTimeout sets the timeout for the callback of an ExecuteScriptAsync call to be called. 61 SetScriptTimeout(context.Context, time.Duration) error 62 // Logs gets logs of the specified type from the remote end. 63 Logs(ctx context.Context, logType string) ([]LogEntry, error) 64 // SessionID returns the id for this session. 65 SessionID() string 66 // Address returns the base address for this sessions (ending with session/<SessionID>) 67 Address() *url.URL 68 // Capabilities returns the capabilities returned from the remote end when session was created. 69 Capabilities() map[string]interface{} 70 // Screenshot takes a screenshot of the current browser window. 71 Screenshot(context.Context) (image.Image, error) 72 // WindowHandles returns a slice of the current window handles. 73 WindowHandles(context.Context) ([]string, error) 74 // ElementFromID returns a new WebElement object for the given id. 75 ElementFromID(string) WebElement 76 // ElementFromMap returns a new WebElement from a map representing a JSON object. 77 ElementFromMap(map[string]interface{}) (WebElement, error) 78 // GetWindowRect returns the current windows size and location. 79 GetWindowRect(context.Context) (Rectangle, error) 80 // SetWindowRect sets the current window size and location. 81 SetWindowRect(context.Context, Rectangle) error 82 // SetWindowSize sets the current window size. 83 SetWindowSize(ctx context.Context, width, height float64) error 84 // SetWindowPosition sest the current window position. 85 SetWindowPosition(ctx context.Context, x, y float64) error 86 // W3C return true iff connected to a W3C compliant remote end. 87 W3C() bool 88 // CurrentURL returns the URL that the current browser window is looking at. 89 CurrentURL(context.Context) (*url.URL, error) 90 // PageSource returns the source of the current browsing context active document. 91 PageSource(context.Context) (string, error) 92 // NavigateTo navigates the controlled browser to the specified URL. 93 NavigateTo(context.Context, *url.URL) error 94 } 95 96 // WebElement provides access to a specific DOM element in a WebDriver session. 97 type WebElement interface { 98 // ID returns the WebDriver element id. 99 ID() string 100 // ToMap returns a Map representation of a WebElement suitable for use in other WebDriver commands. 101 ToMap() map[string]string 102 // ScrollIntoView scrolls a WebElement to the top of the browsers viewport. 103 ScrollIntoView(ctx context.Context) error 104 // Bounds returns the bounds of the WebElement within the viewport. 105 // This will not scroll the element into the viewport first. 106 // Will return an error if the element is not in the viewport. 107 Bounds(ctx context.Context) (image.Rectangle, error) 108 } 109 110 // Rectangle represents a window's position and size. 111 type Rectangle struct { 112 X float64 `json:"x"` 113 Y float64 `json:"y"` 114 Width float64 `json:"width"` 115 Height float64 `json:"height"` 116 } 117 118 // LogEntry is an entry parsed from the logs retrieved from the remote WebDriver. 119 type LogEntry struct { 120 Timestamp float64 `json:"timestamp"` 121 Level string `json:"level"` 122 Message string `json:"message"` 123 } 124 125 type webDriver struct { 126 address *url.URL 127 sessionID string 128 capabilities map[string]interface{} 129 client *http.Client 130 scriptTimeout time.Duration 131 w3c bool 132 } 133 134 type webElement struct { 135 driver *webDriver 136 id string 137 } 138 139 type jsonResp struct { 140 Status *int `json:"status"` 141 SessionID string `json:"sessionId"` 142 Value interface{} `json:"value"` 143 Error string `json:"error"` 144 Message string `json:"message"` 145 StackTrace interface{} `json:"stacktrace"` 146 } 147 148 func (j *jsonResp) isError() bool { 149 if j.Status != nil && *j.Status != 0 { 150 return true 151 } 152 153 if j.Error != "" { 154 return true 155 } 156 157 value, ok := j.Value.(map[string]interface{}) 158 if !ok { 159 return false 160 } 161 162 e, ok := value["error"].(string) 163 return ok && e != "" 164 } 165 166 // CreateSession creates a new WebDriver session with desired capabilities from server at addr 167 // and ensures that the browser connection is working. It retries up to attempts - 1 times. 168 func CreateSession(ctx context.Context, addr string, attempts int, requestedCaps *capabilities.Capabilities) (WebDriver, error) { 169 reqBody := requestedCaps.ToMixedMode() 170 171 urlPrefix, err := url.Parse(addr) 172 if err != nil { 173 return nil, errors.New(compName, err) 174 } 175 176 urlSuffix, err := url.Parse("session/") 177 if err != nil { 178 return nil, errors.New(compName, err) 179 } 180 181 fullURL := urlPrefix.ResolveReference(urlSuffix) 182 c, err := command(fullURL, "") 183 if err != nil { 184 return nil, err 185 } 186 187 client := &http.Client{} 188 189 for ; attempts > 0; attempts-- { 190 d, err := func() (*webDriver, error) { 191 respBody, err := post(ctx, client, c, reqBody, nil) 192 if err != nil { 193 return nil, err 194 } 195 196 val, ok := respBody.Value.(map[string]interface{}) 197 if !ok { 198 return nil, errors.New(compName, fmt.Errorf("value field must be an object in %+v", respBody)) 199 } 200 201 var caps map[string]interface{} 202 203 session := respBody.SessionID 204 if session != "" { 205 // OSS protocol puts Session ID at the top level: 206 // { 207 // "value": { capabilities object }, 208 // "sessionId": "id", 209 // "status": 0 210 // } 211 caps = val 212 } else { 213 // W3C protocol wraps everything in a "value" key: 214 // { 215 // "value": { 216 // "capabilities": { capabilities object }, 217 // "sessionId": "id" 218 // } 219 // } 220 session, _ = val["sessionId"].(string) 221 if session == "" { 222 return nil, errors.New(compName, fmt.Errorf("no session id specified in %+v", respBody)) 223 } 224 caps, ok = val["capabilities"].(map[string]interface{}) 225 if !ok { 226 return nil, errors.New(compName, fmt.Errorf("no capabilities in value of %+v", respBody)) 227 } 228 } 229 230 sessionURL, err := url.Parse(session + "/") 231 if err != nil { 232 return nil, errors.New(compName, err) 233 } 234 235 d := &webDriver{ 236 address: fullURL.ResolveReference(sessionURL), 237 sessionID: session, 238 capabilities: caps, 239 client: client, 240 scriptTimeout: scriptTimeout(requestedCaps), 241 w3c: respBody.Status == nil, 242 } 243 244 if err := d.Healthy(ctx); err != nil { 245 if err := d.Quit(ctx); err != nil { 246 log.Printf("error quitting WebDriver session: %v", err) 247 } 248 return nil, err 249 } 250 return d, nil 251 }() 252 253 if err == nil { 254 return d, nil 255 } 256 if errors.IsPermanent(err) || attempts <= 1 { 257 return nil, err 258 } 259 } 260 261 // This should only occur if called with attempts <= 0 262 return nil, errors.New(compName, fmt.Errorf("attempts %d <= 0", attempts)) 263 } 264 265 func (d *webDriver) W3C() bool { 266 return d.w3c 267 } 268 269 func (d *webDriver) Address() *url.URL { 270 return d.address 271 } 272 273 func (d *webDriver) Capabilities() map[string]interface{} { 274 return d.capabilities 275 } 276 277 func (d *webDriver) SessionID() string { 278 return d.sessionID 279 } 280 281 func (*webDriver) Name() string { 282 return compName 283 } 284 285 func (d *webDriver) Healthy(ctx context.Context) error { 286 return d.ExecuteScript(ctx, "return navigator.userAgent", nil, nil) 287 } 288 289 func (d *webDriver) ExecuteScript(ctx context.Context, script string, args []interface{}, value interface{}) error { 290 if args == nil { 291 args = []interface{}{} 292 } 293 body := map[string]interface{}{ 294 "script": script, 295 "args": args, 296 } 297 command := "execute" 298 if d.W3C() { 299 command = "execute/sync" 300 } 301 return d.post(ctx, command, body, value) 302 } 303 304 func (d *webDriver) ExecuteScriptAsync(ctx context.Context, script string, args []interface{}, value interface{}) error { 305 if args == nil { 306 args = []interface{}{} 307 } 308 body := map[string]interface{}{ 309 "script": script, 310 "args": args, 311 } 312 command := "execute_async" 313 if d.W3C() { 314 command = "execute/async" 315 } 316 err := d.post(ctx, command, body, value) 317 return err 318 } 319 320 func (d *webDriver) ExecuteScriptAsyncWithTimeout(ctx context.Context, timeout time.Duration, script string, args []interface{}, value interface{}) error { 321 if err := d.setScriptTimeout(ctx, timeout); err != nil { 322 log.Printf("error setting script timeout to %v", timeout) 323 } 324 if d.scriptTimeout != 0 { 325 defer func() { 326 if err := d.setScriptTimeout(ctx, d.scriptTimeout); err != nil { 327 log.Printf("error restoring script timeout to %v", d.scriptTimeout) 328 } 329 }() 330 } 331 return d.ExecuteScriptAsync(ctx, script, args, value) 332 } 333 334 // CurrentURL returns the URL that the current browser window is looking at. 335 func (d *webDriver) CurrentURL(ctx context.Context) (*url.URL, error) { 336 var result string 337 338 if err := d.get(ctx, "url", &result); err != nil { 339 return nil, err 340 } 341 342 current, err := url.Parse(result) 343 if err != nil { 344 return current, errors.New(d.Name(), err) 345 } 346 return current, nil 347 } 348 349 // PageSource returns the source of the current browsing context active document. 350 func (d *webDriver) PageSource(ctx context.Context) (string, error) { 351 var result string 352 353 if err := d.get(ctx, "source", &result); err != nil { 354 return "", err 355 } 356 return result, nil 357 } 358 359 // NavigateTo navigates the controlled browser to the specified URL. 360 func (d *webDriver) NavigateTo(ctx context.Context, u *url.URL) error { 361 return d.post(ctx, "url", map[string]interface{}{ 362 "url": u.String(), 363 }, nil) 364 } 365 366 // Screenshot takes a screenshot of the current browser window. 367 func (d *webDriver) Screenshot(ctx context.Context) (image.Image, error) { 368 var value string 369 if err := d.get(ctx, "screenshot", &value); err != nil { 370 return nil, err 371 } 372 return png.Decode(base64.NewDecoder(base64.StdEncoding, strings.NewReader(value))) 373 } 374 375 // WindowHandles returns a slice of the current window handles. 376 func (d *webDriver) WindowHandles(ctx context.Context) ([]string, error) { 377 var value []string 378 command := "window_handles" 379 if d.W3C() { 380 command = "window/handles" 381 } 382 if err := d.get(ctx, command, &value); err != nil { 383 return nil, err 384 } 385 return value, nil 386 } 387 388 func (d *webDriver) GetWindowRect(ctx context.Context) (result Rectangle, err error) { 389 if d.W3C() { 390 err = d.get(ctx, "window/rect", &result) 391 return 392 } 393 394 err = d.get(ctx, "window/current/size", &result) 395 if err != nil { 396 return 397 } 398 err = d.get(ctx, "window/current/position", &result) 399 return 400 } 401 402 func (d *webDriver) SetWindowRect(ctx context.Context, rect Rectangle) error { 403 if d.W3C() { 404 return d.post(ctx, "window/rect", rect, nil) 405 } 406 407 if err := d.SetWindowSize(ctx, rect.Width, rect.Height); err != nil { 408 return err 409 } 410 411 return d.SetWindowPosition(ctx, rect.X, rect.Y) 412 } 413 414 func (d *webDriver) SetWindowSize(ctx context.Context, width, height float64) error { 415 args := map[string]float64{ 416 "width": width, 417 "height": height, 418 } 419 command := "window/current/size" 420 if d.W3C() { 421 command = "window/rect" 422 } 423 return d.post(ctx, command, args, nil) 424 } 425 426 func (d *webDriver) SetWindowPosition(ctx context.Context, x, y float64) error { 427 args := map[string]float64{ 428 "x": x, 429 "y": y, 430 } 431 command := "window/current/position" 432 if d.W3C() { 433 command = "window/rect" 434 } 435 return d.post(ctx, command, args, nil) 436 } 437 438 func (d *webDriver) post(ctx context.Context, suffix string, body interface{}, value interface{}) error { 439 c, err := d.CommandURL(suffix) 440 if err != nil { 441 return err 442 } 443 444 _, err = post(ctx, d.client, c, body, value) 445 return err 446 } 447 448 func (d *webDriver) get(ctx context.Context, suffix string, value interface{}) error { 449 c, err := d.CommandURL(suffix) 450 if err != nil { 451 return err 452 } 453 _, err = getReq(ctx, d.client, c, value) 454 return err 455 } 456 457 func (d *webDriver) delete(ctx context.Context, suffix string, value interface{}) error { 458 c, err := d.CommandURL(suffix) 459 if err != nil { 460 return err 461 } 462 _, err = deleteReq(ctx, d.client, c, value) 463 return err 464 } 465 466 func (d *webDriver) Quit(ctx context.Context) error { 467 return d.delete(ctx, "", nil) 468 } 469 470 func (d *webDriver) CommandURL(endpoint ...string) (*url.URL, error) { 471 return command(d.Address(), endpoint...) 472 } 473 474 func (d *webDriver) SetScriptTimeout(ctx context.Context, timeout time.Duration) error { 475 d.scriptTimeout = timeout 476 return d.setScriptTimeout(ctx, timeout) 477 } 478 479 func (d *webDriver) setScriptTimeout(ctx context.Context, timeout time.Duration) error { 480 if d.W3C() { 481 return d.post(ctx, "timeouts", map[string]interface{}{ 482 "script": int(timeout / time.Millisecond), 483 }, nil) 484 } 485 return d.post(ctx, "timeouts", map[string]interface{}{ 486 "type": "script", 487 "ms": int(timeout / time.Millisecond), 488 }, nil) 489 } 490 491 func (d *webDriver) Logs(ctx context.Context, logType string) ([]LogEntry, error) { 492 body := map[string]interface{}{"type": logType} 493 var entries []LogEntry 494 err := d.post(ctx, "log", body, &entries) 495 if err != nil { 496 return nil, err 497 } 498 return entries, nil 499 } 500 501 // ElementFromID returns a new WebElement object for the given id. 502 func (d *webDriver) ElementFromID(id string) WebElement { 503 return &webElement{driver: d, id: id} 504 } 505 506 // ElementFromMap returns a new WebElement from a map representing a JSON object. 507 func (d *webDriver) ElementFromMap(m map[string]interface{}) (WebElement, error) { 508 i, ok := m[w3cElementKey] 509 if !ok { 510 i, ok = m[seleniumElementKey] 511 if !ok { 512 return nil, errors.New(d.Name(), fmt.Errorf("map %v does not appear to represent a WebElement", m)) 513 } 514 } 515 516 id, ok := i.(string) 517 if !ok { 518 return nil, errors.New(d.Name(), fmt.Errorf("map %v does not appear to represent a WebElement", m)) 519 } 520 return d.ElementFromID(id), nil 521 } 522 523 func command(addr *url.URL, endpoint ...string) (*url.URL, error) { 524 u, err := addr.Parse(path.Join(endpoint...)) 525 if err != nil { 526 return nil, err 527 } 528 return &url.URL{ 529 Scheme: u.Scheme, 530 Opaque: u.Opaque, 531 User: u.User, 532 Host: u.Host, 533 // Some remote ends (notably chromedriver) do not like a trailing slash 534 Path: strings.TrimRight(u.Path, "/"), 535 RawPath: strings.TrimRight(u.RawPath, "/"), 536 RawQuery: u.RawQuery, 537 Fragment: u.Fragment, 538 }, nil 539 } 540 541 func processResponse(body io.Reader, value interface{}) (*jsonResp, error) { 542 bytes, err := ioutil.ReadAll(body) 543 if err != nil { 544 return nil, errors.New(compName, err) 545 } 546 547 respBody := &jsonResp{Value: value} 548 if err := json.Unmarshal(bytes, respBody); err != nil || respBody.isError() { 549 if value != nil { 550 // Reparsing to ensure we have a clean value. 551 respBody = &jsonResp{} 552 553 if err := json.Unmarshal(bytes, respBody); err != nil { 554 // The body was unparseable, so returning an error 555 return nil, errors.New(compName, fmt.Errorf("%v unmarshalling %q", err, string(bytes))) 556 } 557 } 558 559 if respBody.isError() { 560 // The remote end returned an error. Return the body and an error constructed from the body. 561 return respBody, newWebDriverError(respBody) 562 } 563 564 // The body was unparseable with the passed in value, but was otherwise parseable and not an error value. 565 // Return the body and an error indicating that the original parse failed. 566 return respBody, errors.New(compName, fmt.Errorf("%v unmarshalling %+v", err, respBody)) 567 } 568 569 // Everything is good. Return the body. 570 return respBody, nil 571 } 572 573 func post(ctx context.Context, client *http.Client, command *url.URL, body interface{}, value interface{}) (*jsonResp, error) { 574 reqBody, err := json.Marshal(body) 575 if err != nil { 576 return nil, errors.NewPermanent(compName, err) 577 } 578 579 request, err := http.NewRequest("POST", command.String(), bytes.NewReader(reqBody)) 580 if err != nil { 581 return nil, errors.NewPermanent(compName, err) 582 } 583 584 request.TransferEncoding = []string{"identity"} 585 request.Header.Set("Content-Type", "application/json; charset=utf-8") 586 request.ContentLength = int64(len(reqBody)) 587 588 return doRequest(ctx, client, request, value) 589 } 590 591 func deleteReq(ctx context.Context, client *http.Client, command *url.URL, value interface{}) (*jsonResp, error) { 592 request, err := http.NewRequest("DELETE", command.String(), nil) 593 if err != nil { 594 return nil, errors.NewPermanent(compName, err) 595 } 596 597 return doRequest(ctx, client, request, value) 598 } 599 600 func getReq(ctx context.Context, client *http.Client, command *url.URL, value interface{}) (*jsonResp, error) { 601 request, err := http.NewRequest("GET", command.String(), nil) 602 if err != nil { 603 return nil, errors.NewPermanent(compName, err) 604 } 605 606 return doRequest(ctx, client, request, value) 607 } 608 609 func doRequest(ctx context.Context, client *http.Client, request *http.Request, value interface{}) (*jsonResp, error) { 610 request.Header.Set("Cache-Control", "no-cache") 611 request.Header.Set("Accept", "application/json") 612 request.Header.Set("Accept-Encoding", "identity") 613 request = request.WithContext(ctx) 614 resp, err := client.Do(request) 615 if err != nil { 616 return nil, errors.New(compName, err) 617 } 618 619 defer resp.Body.Close() 620 r, err := processResponse(resp.Body, value) 621 return r, err 622 } 623 624 // ID returns the WebDriver element id. 625 func (e *webElement) ID() string { 626 return e.id 627 } 628 629 // ToMap returns a Map representation of a WebElement suitable for use in other WebDriver commands. 630 func (e *webElement) ToMap() map[string]string { 631 return map[string]string{ 632 seleniumElementKey: e.ID(), 633 w3cElementKey: e.ID(), 634 } 635 } 636 637 // ScrollIntoView scrolls a WebElement to the top of the browsers viewport. 638 func (e *webElement) ScrollIntoView(ctx context.Context) error { 639 const script = `return arguments[0].scrollIntoView(true);` 640 args := []interface{}{e.ToMap()} 641 return e.driver.ExecuteScript(ctx, script, args, nil) 642 } 643 644 // Bounds returns the bounds of the WebElement within the viewport. 645 // This will not scroll the element into the viewport first. 646 // Will return an error if the element is not in the viewport. 647 func (e *webElement) Bounds(ctx context.Context) (image.Rectangle, error) { 648 const script = ` 649 var element = arguments[0]; 650 var rect = element.getBoundingClientRect(); 651 var top = rect.top; var left = rect.left; 652 element = window.frameElement; 653 var currentWindow = window.parent; 654 while (element != null) { 655 var currentRect = element.getBoundingClientRect(); 656 top += currentRect.top; 657 left += currentRect.left; 658 element = currentWindow.frameElement; 659 currentWindow = currentWindow.parent; 660 } 661 return {"X0": Math.round(left), "Y0": Math.round(top), "X1": Math.round(left + rect.width), "Y1": Math.round(top + rect.height)}; 662 ` 663 bounds := struct { 664 X0 int 665 Y0 int 666 X1 int 667 Y1 int 668 }{} 669 args := []interface{}{e.ToMap()} 670 err := e.driver.ExecuteScript(ctx, script, args, &bounds) 671 log.Printf("Err: %v", err) 672 return image.Rect(bounds.X0, bounds.Y0, bounds.X1, bounds.Y1), err 673 } 674 675 func scriptTimeout(caps *capabilities.Capabilities) time.Duration { 676 if caps == nil { 677 return 0 678 } 679 timeouts, ok := caps.AlwaysMatch["timeouts"].(map[string]interface{}) 680 if !ok { 681 return 0 682 } 683 684 if script, ok := timeouts["script"].(int); ok { 685 return time.Duration(script) * time.Millisecond 686 } 687 688 if script, ok := timeouts["script"].(float64); ok { 689 return time.Duration(script) * time.Millisecond 690 } 691 692 return 0 693 }