From f02d45c1957972b340938798f3fd645b065e2eca Mon Sep 17 00:00:00 2001 From: Johan Brandhorst Date: Sat, 15 Jul 2017 14:34:57 +0100 Subject: [PATCH] Add Screenshot function to Selection. Fixes #104. Adds Screenshot function to Selection and GetScreenshot to the element API. --- api/element.go | 22 ++++ api/element_test.go | 61 ++++++++++++ injector_test.go | 8 +- internal/element/repository.go | 2 + internal/integration/selection_test.go | 21 +++- internal/mocks/crop.go | 22 ++++ internal/mocks/element.go | 21 ++++ multiselection_test.go | 2 +- selection.go | 65 ++++++++++++ selection_actions_test.go | 2 +- selection_frames_test.go | 2 +- selection_properties_test.go | 2 +- selection_test.go | 133 +++++++++++++++++++++++-- types.go | 7 ++ 14 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 internal/mocks/crop.go diff --git a/api/element.go b/api/element.go index 0e542f0..6f929bf 100644 --- a/api/element.go +++ b/api/element.go @@ -1,6 +1,7 @@ package api import ( + "encoding/base64" "errors" "path" "strings" @@ -145,3 +146,24 @@ func (e *Element) GetLocation() (x, y int, err error) { func round(number float64) int { return int(number + 0.5) } + +func (e *Element) GetRect() (x, y, width, height int, err error) { + var rect struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Height float64 `json:"height"` + Width float64 `json:"width"` + } + if err := e.Send("GET", "rect", nil, &rect); err != nil { + return 0, 0, 0, 0, err + } + return round(rect.X), round(rect.Y), round(rect.Width), round(rect.Height), nil +} + +func (e *Element) GetScreenshot() ([]byte, error) { + var base64Image string + if err := e.Send("GET", "screenshot", nil, &base64Image); err != nil { + return nil, err + } + return base64.StdEncoding.DecodeString(base64Image) +} diff --git a/api/element_test.go b/api/element_test.go index 8342273..c653502 100644 --- a/api/element_test.go +++ b/api/element_test.go @@ -386,4 +386,65 @@ var _ = Describe("Element", func() { }) }) }) + + Describe("#GetRect", func() { + It("should successfully send a GET request to the rect endpoint", func() { + _, _, _, _, err := element.GetRect() + Expect(err).NotTo(HaveOccurred()) + Expect(bus.SendCall.Method).To(Equal("GET")) + Expect(bus.SendCall.Endpoint).To(Equal("element/some-id/rect")) + }) + + It("should return the rounded rect of the element", func() { + bus.SendCall.Result = `{"x": 100.7, "y": 200, "height": 55.05, "width": 33}` + x, y, width, height, err := element.GetRect() + Expect(err).NotTo(HaveOccurred()) + Expect(x).To(Equal(101)) + Expect(y).To(Equal(200)) + Expect(width).To(Equal(33)) + Expect(height).To(Equal(55)) + }) + + Context("when the bus indicates a failure", func() { + It("should return an error indicating the bus failed to retrieve the rect", func() { + bus.SendCall.Err = errors.New("some error") + _, _, _, _, err := element.GetRect() + Expect(err).To(MatchError("some error")) + }) + }) + }) + + Describe("#GetScreenshot", func() { + It("should successfully send a GET request to the screenshot endpoint", func() { + _, err := element.GetScreenshot() + Expect(err).NotTo(HaveOccurred()) + Expect(bus.SendCall.Method).To(Equal("GET")) + Expect(bus.SendCall.Endpoint).To(Equal("element/some-id/screenshot")) + }) + + Context("when the image is valid base64", func() { + It("should return the decoded image", func() { + bus.SendCall.Result = `"c29tZS1wbmc="` + image, err := element.GetScreenshot() + Expect(err).NotTo(HaveOccurred()) + Expect(string(image)).To(Equal("some-png")) + }) + }) + + Context("when the image is not valid base64", func() { + It("should return an error", func() { + bus.SendCall.Result = `"..."` + _, err := element.GetScreenshot() + Expect(err).To(MatchError("illegal base64 data at input byte 0")) + }) + }) + + Context("when the bus indicates a failure", func() { + It("should return an error", func() { + bus.SendCall.Err = errors.New("some error") + _, err := element.GetScreenshot() + Expect(err).To(MatchError("some error")) + }) + }) + }) }) diff --git a/injector_test.go b/injector_test.go index 1feae03..6143bfd 100644 --- a/injector_test.go +++ b/injector_test.go @@ -2,14 +2,14 @@ package agouti import "github.com/sclevine/agouti/internal/target" -func NewTestSelection(session apiSession, elements elementRepository, firstSelector string) *Selection { +func NewTestSelection(session apiSession, elements elementRepository, firstSelector string, cropper Cropper) *Selection { selector := target.Selector{Type: target.CSS, Value: firstSelector, Single: true} - return &Selection{selectable{session, target.Selectors{selector}}, elements} + return &Selection{selectable{session, target.Selectors{selector}}, elements, cropper} } -func NewTestMultiSelection(session apiSession, elements elementRepository, firstSelector string) *MultiSelection { +func NewTestMultiSelection(session apiSession, elements elementRepository, firstSelector string, cropper Cropper) *MultiSelection { selector := target.Selector{Type: target.CSS, Value: firstSelector} - selection := Selection{selectable{session, target.Selectors{selector}}, elements} + selection := Selection{selectable{session, target.Selectors{selector}}, elements, cropper} return &MultiSelection{selection} } diff --git a/internal/element/repository.go b/internal/element/repository.go index 4749ddf..8074b17 100644 --- a/internal/element/repository.go +++ b/internal/element/repository.go @@ -34,6 +34,8 @@ type Element interface { Value(text string) error Submit() error GetLocation() (x, y int, err error) + GetRect() (x, y, width, height int, err error) + GetScreenshot() ([]byte, error) } func (e *Repository) GetAtLeastOne() ([]Element, error) { diff --git a/internal/integration/selection_test.go b/internal/integration/selection_test.go index 2890051..de6502a 100644 --- a/internal/integration/selection_test.go +++ b/internal/integration/selection_test.go @@ -1,13 +1,17 @@ package integration_test import ( + "image" + "image/png" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/sclevine/agouti" . "github.com/sclevine/agouti/matchers" - "io/ioutil" - "net/http" - "net/http/httptest" ) func testSelection(browserName string, newPage pageFunc) { @@ -187,5 +191,16 @@ func testSelection(browserName string, newPage pageFunc) { Eventually(func() bool { return submitted }).Should(BeTrue()) }) }) + + It("should support taking screenshots", func() { + selection := page.Find("a") + Expect(selection.Screenshot(".test.screenshot.png")).To(Succeed()) + defer os.Remove(".test.screenshot.png") + file, _ := os.Open(".test.screenshot.png") + img, err := png.Decode(file) + Expect(err).NotTo(HaveOccurred()) + // Check screenshot is of element only + Expect(img.Bounds().Size()).To(Equal(image.Point{X: 71, Y: 19})) + }) }) } diff --git a/internal/mocks/crop.go b/internal/mocks/crop.go new file mode 100644 index 0000000..e32772f --- /dev/null +++ b/internal/mocks/crop.go @@ -0,0 +1,22 @@ +package mocks + +import ( + "image" +) + +type Cropper struct { + Image image.Image + Width int + Height int + Anchor image.Point + ReturnImage image.Image + Err error +} + +func (c *Cropper) Crop(img image.Image, width, height int, anchor image.Point) (image.Image, error) { + c.Image = img + c.Width = width + c.Height = height + c.Anchor = anchor + return c.ReturnImage, c.Err +} diff --git a/internal/mocks/element.go b/internal/mocks/element.go index e405fac..0e3c1c0 100644 --- a/internal/mocks/element.go +++ b/internal/mocks/element.go @@ -87,6 +87,19 @@ type Element struct { ReturnY int Err error } + + GetRectCall struct { + ReturnX int + ReturnY int + ReturnHeight int + ReturnWidth int + Err error + } + + GetScreenshotCall struct { + ReturnImage []byte + Err error + } } func (e *Element) GetElement(selector api.Selector) (*api.Element, error) { @@ -161,3 +174,11 @@ func (e *Element) IsEqualTo(other *api.Element) (bool, error) { func (e *Element) GetLocation() (x, y int, err error) { return e.GetLocationCall.ReturnX, e.GetLocationCall.ReturnY, e.GetLocationCall.Err } + +func (e *Element) GetRect() (x, y, width, height int, err error) { + return e.GetRectCall.ReturnX, e.GetRectCall.ReturnY, e.GetRectCall.ReturnWidth, e.GetRectCall.ReturnHeight, e.GetRectCall.Err +} + +func (e *Element) GetScreenshot() (s []byte, err error) { + return e.GetScreenshotCall.ReturnImage, e.GetScreenshotCall.Err +} diff --git a/multiselection_test.go b/multiselection_test.go index 6f656af..ede8613 100644 --- a/multiselection_test.go +++ b/multiselection_test.go @@ -18,7 +18,7 @@ var _ = Describe("MultiSelection", func() { BeforeEach(func() { bus = &mocks.Bus{} session = &api.Session{Bus: bus} - selection = NewTestMultiSelection(session, nil, "#selector") + selection = NewTestMultiSelection(session, nil, "#selector", nil) }) Describe("#At", func() { diff --git a/selection.go b/selection.go index 1a32096..8b7060b 100644 --- a/selection.go +++ b/selection.go @@ -1,9 +1,15 @@ package agouti import ( + "bytes" "fmt" + "image" + "image/png" + "io/ioutil" + "path/filepath" "github.com/sclevine/agouti/api" + "github.com/sclevine/agouti/crop" "github.com/sclevine/agouti/internal/element" "github.com/sclevine/agouti/internal/target" ) @@ -24,6 +30,7 @@ import ( type Selection struct { selectable elements elementRepository + cropper Cropper } type elementRepository interface { @@ -39,6 +46,7 @@ func newSelection(session apiSession, selectors target.Selectors) *Selection { Client: session, Selectors: selectors, }, + crop.CropperFunc(crop.Crop), } } @@ -115,3 +123,60 @@ func (s *Selection) MouseToElement() error { return nil } + +// Screenshot takes a screenshot of exactly one element +// and saves it to the provided filename. +// The provided filename may be an absolute or relative path. +func (s *Selection) Screenshot(filename string) error { + selectedElement, err := s.elements.GetExactlyOne() + if err != nil { + return fmt.Errorf("failed to select element from %s: %s", s, err) + } + + absFilePath, err := filepath.Abs(filename) + if err != nil { + return fmt.Errorf("failed to find absolute path for filename: %s", err) + } + + screenshot, err := selectedElement.GetScreenshot() + if err != nil { + // Fallback to getting full size screenshot and cropping + data, err := s.session.GetScreenshot() + if err != nil { + return fmt.Errorf("failed to retrieve screenshot: %s", err) + } + + img, err := png.Decode(bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("failed to decode screenshot: %s", err) + } + + x, y, width, height, err := selectedElement.GetRect() + if err != nil { + return fmt.Errorf("failed to retrieve bounds for selection: %s", err) + } + + fmt.Println(x, y, width, height) + + croppedImg, err := s.cropper.Crop( + img, width, height, + image.Point{X: x, Y: y}) + if err != nil { + return fmt.Errorf("failed to crop screenshot: %s", err) + } + + b := new(bytes.Buffer) + err = png.Encode(b, croppedImg) + if err != nil { + return fmt.Errorf("failed to encode screenshot: %s", err) + } + + screenshot = b.Bytes() + } + + if err := ioutil.WriteFile(absFilePath, screenshot, 0666); err != nil { + return fmt.Errorf("failed to save screenshot: %s", err) + } + + return nil +} diff --git a/selection_actions_test.go b/selection_actions_test.go index fad22cc..6a99aed 100644 --- a/selection_actions_test.go +++ b/selection_actions_test.go @@ -27,7 +27,7 @@ var _ = Describe("Selection Actions", func() { firstElement = &mocks.Element{} secondElement = &mocks.Element{} elementRepository = &mocks.ElementRepository{} - selection = NewTestMultiSelection(session, elementRepository, "#selector") + selection = NewTestMultiSelection(session, elementRepository, "#selector", nil) elementRepository.GetAtLeastOneCall.ReturnElements = []element.Element{firstElement, secondElement} }) diff --git a/selection_frames_test.go b/selection_frames_test.go index 3362490..e921053 100644 --- a/selection_frames_test.go +++ b/selection_frames_test.go @@ -21,7 +21,7 @@ var _ = Describe("Selection Frames", func() { BeforeEach(func() { session = &mocks.Session{} elementRepository = &mocks.ElementRepository{} - selection = NewTestSelection(session, elementRepository, "#selector") + selection = NewTestSelection(session, elementRepository, "#selector", nil) }) Describe("#SwitchToFrame", func() { diff --git a/selection_properties_test.go b/selection_properties_test.go index 5a92910..5e97e5a 100644 --- a/selection_properties_test.go +++ b/selection_properties_test.go @@ -26,7 +26,7 @@ var _ = Describe("Selection Properties", func() { firstElement = &mocks.Element{} secondElement = &mocks.Element{} elementRepository = &mocks.ElementRepository{} - selection = NewTestMultiSelection(session, elementRepository, "#selector") + selection = NewTestMultiSelection(session, elementRepository, "#selector", nil) }) Describe("#Text", func() { diff --git a/selection_test.go b/selection_test.go index b2e0543..d386b27 100644 --- a/selection_test.go +++ b/selection_test.go @@ -2,6 +2,14 @@ package agouti_test import ( "errors" + "io/ioutil" + "os" + "path/filepath" + + "bytes" + "image/png" + + "image" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -12,6 +20,15 @@ import ( "github.com/sclevine/agouti/internal/mocks" ) +var minimalpng = []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, + 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, + 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x11, + 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x62, 0x60, 0x60, + 0x60, 0x00, 0x04, 0x00, 0x00, 0xff, 0xff, 0x00, 0x0f, 0x00, + 0x03, 0xfe, 0x8f, 0xeb, 0xcf, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82} + var _ = Describe("Selection", func() { var ( firstElement *mocks.Element @@ -25,7 +42,7 @@ var _ = Describe("Selection", func() { Describe("#String", func() { It("should return a string representation of the selection", func() { - selection := NewTestMultiSelection(nil, nil, "#selector") + selection := NewTestMultiSelection(nil, nil, "#selector", nil) Expect(selection.AllByXPath("#subselector").String()).To(Equal("selection 'CSS: #selector | XPath: #subselector'")) }) }) @@ -38,7 +55,7 @@ var _ = Describe("Selection", func() { BeforeEach(func() { elementRepository = &mocks.ElementRepository{} - selection = NewTestSelection(nil, elementRepository, "#selector") + selection = NewTestSelection(nil, elementRepository, "#selector", nil) }) It("should return a []*api.Elements retrieved from the element repository", func() { @@ -64,7 +81,7 @@ var _ = Describe("Selection", func() { BeforeEach(func() { elementRepository = &mocks.ElementRepository{} - selection = NewTestMultiSelection(nil, elementRepository, "#selector") + selection = NewTestMultiSelection(nil, elementRepository, "#selector", nil) elementRepository.GetCall.ReturnElements = []element.Element{firstElement, secondElement} }) @@ -92,11 +109,11 @@ var _ = Describe("Selection", func() { BeforeEach(func() { firstElementRepository = &mocks.ElementRepository{} firstElementRepository.GetExactlyOneCall.ReturnElement = firstElement - firstSelection = NewTestSelection(nil, firstElementRepository, "#first_selector") + firstSelection = NewTestSelection(nil, firstElementRepository, "#first_selector", nil) secondElementRepository = &mocks.ElementRepository{} secondElementRepository.GetExactlyOneCall.ReturnElement = secondElement - secondSelection = NewTestSelection(nil, secondElementRepository, "#second_selector") + secondSelection = NewTestSelection(nil, secondElementRepository, "#second_selector", nil) }) It("should compare the selection elements for equality", func() { @@ -116,7 +133,7 @@ var _ = Describe("Selection", func() { Context("when the provided object is a *MultiSelection", func() { It("should not fail", func() { - multiSelection := NewTestMultiSelection(nil, secondElementRepository, "#multi_selector") + multiSelection := NewTestMultiSelection(nil, secondElementRepository, "#multi_selector", nil) Expect(firstSelection.EqualsElement(multiSelection)).To(BeFalse()) Expect(firstElement.IsEqualToCall.Element).To(ExactlyEqual(secondElement)) }) @@ -165,7 +182,7 @@ var _ = Describe("Selection", func() { elementRepository = &mocks.ElementRepository{} elementRepository.GetExactlyOneCall.ReturnElement = secondElement session = &mocks.Session{} - selection = NewTestSelection(session, elementRepository, "#selector") + selection = NewTestSelection(session, elementRepository, "#selector", nil) }) It("should successfully instruct the session to move the mouse over the selection", func() { @@ -190,4 +207,106 @@ var _ = Describe("Selection", func() { }) }) }) + + Describe("#Screenshot", func() { + var ( + selection *Selection + session *mocks.Session + cropper *mocks.Cropper + firstElement *mocks.Element + elementRepository *mocks.ElementRepository + ) + + BeforeEach(func() { + firstElement = &mocks.Element{} + elementRepository = &mocks.ElementRepository{} + elementRepository.GetExactlyOneCall.ReturnElement = firstElement + session = &mocks.Session{} + cropper = &mocks.Cropper{} + selection = NewTestSelection(session, elementRepository, "#selector", cropper) + }) + + It("should successfully return the screenshot", func() { + firstElement.GetScreenshotCall.ReturnImage = []byte("some-image") + filename, _ := filepath.Abs(".test.screenshot.png") + Expect(selection.Screenshot(".test.screenshot.png")).To(Succeed()) + defer os.Remove(filename) + result, _ := ioutil.ReadFile(filename) + Expect(string(result)).To(Equal("some-image")) + }) + + Context("when a new screenshot file cannot be saved", func() { + It("should return an error", func() { + err := selection.Screenshot("") + Expect(err.Error()).To(ContainSubstring("failed to save screenshot: open")) + }) + }) + + Context("when the element repository fails to return exactly one element", func() { + It("should return an error", func() { + elementRepository.GetExactlyOneCall.Err = errors.New("some error") + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to select element from selection 'CSS: #selector [single]': some error")) + }) + }) + + Context("when the selection fails to retrieve a screenshot", func() { + BeforeEach(func() { + firstElement.GetScreenshotCall.Err = errors.New("some error") + }) + + It("should fall back to using the session screenshot and cropping", func() { + session.GetScreenshotCall.ReturnImage = minimalpng + cropper.ReturnImage, _ = png.Decode(bytes.NewBuffer(minimalpng)) + filename, _ := filepath.Abs(".test.screenshot.png") + Expect(selection.Screenshot(".test.screenshot.png")).To(Succeed()) + defer os.Remove(filename) + result, _ := ioutil.ReadFile(filename) + Expect(result).To(Equal(minimalpng)) + }) + + Context("and the session fails to retrieve a screenshot", func() { + It("should return an error", func() { + session.GetScreenshotCall.Err = errors.New("some error") + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to retrieve screenshot: some error")) + }) + }) + + Context("and the session screenshot cannot be decoded", func() { + It("should return an error", func() { + session.GetScreenshotCall.ReturnImage = []byte("some-image") + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to decode screenshot: png: invalid format: not a PNG file")) + }) + }) + + Context("and the selections bounding rectangle cannot be retrieved", func() { + It("should return an error", func() { + session.GetScreenshotCall.ReturnImage = minimalpng + firstElement.GetRectCall.Err = errors.New("some error") + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to retrieve bounds for selection: some error")) + }) + }) + + Context("and the image cannot be cropped", func() { + It("should return an error", func() { + session.GetScreenshotCall.ReturnImage = minimalpng + cropper.Err = errors.New("some error") + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to crop screenshot: some error")) + }) + }) + + Context("and the image cannot be encoded", func() { + It("should return an error", func() { + session.GetScreenshotCall.ReturnImage = minimalpng + cropper.ReturnImage = image.Rectangle{} + err := selection.Screenshot(".test.screenshot.png") + Expect(err).To(MatchError("failed to encode screenshot: png: invalid format: invalid image size: 0x0")) + }) + }) + }) + }) }) diff --git a/types.go b/types.go index 67a3f13..8d5f6cb 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,7 @@ package agouti +import "image" + type Tap int const ( @@ -79,3 +81,8 @@ func (c Click) String() string { } return "unknown" } + +// Cropper is the interface used to crop images +type Cropper interface { + Crop(img image.Image, width, height int, anchor image.Point) (image.Image, error) +}