Skip to content

Commit

Permalink
Try padding approach with an overlay
Browse files Browse the repository at this point in the history
Closes cypress-io#4440

Co-authored-by: Minh Nguyen <minhnguyenxx@gmail.com>
  • Loading branch information
sebinsua and NMinhNguyen committed Sep 4, 2019
1 parent 04d8984 commit 086e37c
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 19 deletions.
44 changes: 40 additions & 4 deletions packages/driver/src/cy/commands/screenshot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,45 @@ takeFullPageScreenshot = (state, automationOptions) ->
takeScrollingScreenshots(scrolls, win, state, automationOptions)
.finally(resetScrollOverrides)

takeElementScreenshot = ($el, state, automationOptions) ->
getElementWithPadding = ($el, doc, { padding }) ->
if not padding
return $el

[ paddingTop, paddingRight, paddingBottom, paddingLeft ] = padding

originalElPosition = $dom.getElementPositioning($el)

width = originalElPosition.width
height = originalElPosition.height
top = originalElPosition.fromWindow.top
left = originalElPosition.fromWindow.left

elWithPadding = doc.createElement('div')
elWithPadding.style.position = 'absolute'
elWithPadding.style.visibility = 'hidden'
elWithPadding.style.width = "#{width + paddingLeft + paddingRight}px"
elWithPadding.style.height = "#{height + paddingTop + paddingBottom}px"
elWithPadding.style.top = "#{top - paddingTop}px"
elWithPadding.style.left = "#{left - paddingLeft}px"

doc.body.appendChild(elWithPadding)

$(elWithPadding)

takeElementScreenshot = ($originalEl, state, automationOptions) ->
win = state("window")
doc = state("document")

resetScrollOverrides = scrollOverrides(win, doc)

elPosition = $dom.getElementPositioning($el)
viewportHeight = getViewportHeight(state)
viewportWidth = getViewportWidth(state)
$el = getElementWithPadding(
$originalEl
doc
automationOptions
)
elPosition = $dom.getElementPositioning($el)
numScreenshots = Math.ceil(elPosition.height / viewportHeight)

scrolls = _.map _.times(numScreenshots), (index) ->
Expand Down Expand Up @@ -178,7 +208,11 @@ takeElementScreenshot = ($el, state, automationOptions) ->
{ y, afterScroll }

takeScrollingScreenshots(scrolls, win, state, automationOptions)
.finally(resetScrollOverrides)
.finally(() ->
resetScrollOverrides()
if $el isnt $originalEl
doc.body.removeChild($el[0])
)

## "app only" means we're hiding the runner UI
isAppOnly = ({ capture }) ->
Expand All @@ -193,6 +227,7 @@ getBlackout = ({ capture, blackout }) ->
takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
{
capture
padding
clip
disableTimersAndAnimations
onBeforeScreenshot
Expand Down Expand Up @@ -241,6 +276,7 @@ takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
width: getViewportWidth(state)
height: getViewportHeight(state)
}
padding
userClip: clip
viewport: {
width: $(window).width()
Expand Down Expand Up @@ -318,7 +354,7 @@ module.exports = (Commands, Cypress, cy, state, config) ->

isWin = $dom.isWindow(subject)

screenshotConfig = _.pick(options, "capture", "scale", "disableTimersAndAnimations", "blackout", "waitForCommandSynchronization", "clip", "onBeforeScreenshot", "onAfterScreenshot")
screenshotConfig = _.pick(options, "capture", "scale", "disableTimersAndAnimations", "blackout", "waitForCommandSynchronization", "padding", "clip", "onBeforeScreenshot", "onAfterScreenshot")
screenshotConfig = $Screenshot.validate(screenshotConfig, "cy.screenshot", options._log)
screenshotConfig = _.extend($Screenshot.getConfig(), screenshotConfig)

Expand Down
3 changes: 2 additions & 1 deletion packages/driver/src/cypress/error_messages.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,8 @@ module.exports = {
invalid_capture: "{{cmd}}() 'capture' option must be one of the following: 'fullPage', 'viewport', or 'runner'. You passed: {{arg}}"
invalid_boolean: "{{cmd}}() '{{option}}' option must be a boolean. You passed: {{arg}}"
invalid_blackout: "{{cmd}}() 'blackout' option must be an array of strings. You passed: {{arg}}"
invalid_clip: "{{cmd}}() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: {{arg}}"
invalid_clip: "{{cmd}}() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: {{arg}}"
invalid_padding: "{{cmd}}() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: {{arg}}"
invalid_callback: "{{cmd}}() '{{callback}}' option must be a function. You passed: {{arg}}"
multiple_elements: "#{cmd('screenshot')} only works for a single element. You attempted to screenshot {{numElements}} elements."
timed_out: "#{cmd('screenshot')} timed out waiting '{{timeout}}ms' to complete."
Expand Down
43 changes: 43 additions & 0 deletions packages/driver/src/cypress/screenshot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,40 @@ defaults = reset()

validCaptures = ["fullPage", "viewport", "runner"]

normalizePadding = (padding) ->
if not padding
padding = 0

if _.isArray(padding)
# CSS shorthand
# See: https://developer.mozilla.org/en-US/docs/Web/CSS/Shorthand_properties#Tricky_edge_cases
switch padding.length
when 1
top = right = bottom = left = padding[0]
when 2
top = bottom = padding[0]
right = left = padding[1]
when 3
top = padding[0]
right = left = padding[1]
bottom = padding[2]
when 4
top = padding[0]
right = padding[1]
bottom = padding[2]
left = padding[3]
else
top = right = bottom = left = 0
else
top = right = bottom = left = padding

return [
top
right
bottom
left
]

validateAndSetBoolean = (props, values, cmd, log, option) ->
value = props[option]
if not value?
Expand Down Expand Up @@ -94,6 +128,15 @@ validate = (props, cmd, log) ->

values.clip = clip

if padding = props.padding
if not (_.isFinite(padding) or (_.isArray(padding) and padding.length >=1 && padding.length <= 4 and _.every(padding, _.isFinite)))
$utils.throwErrByPath("screenshot.invalid_padding", {
log: log
args: { cmd: cmd, arg: $utils.stringify(padding) }
})

values.padding = normalizePadding(padding)

validateAndSetCallback(props, values, cmd, log, "onBeforeScreenshot")
validateAndSetCallback(props, values, cmd, log, "onAfterScreenshot")

Expand Down
2 changes: 2 additions & 0 deletions packages/driver/test/cypress/fixtures/screenshots.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
}
.tall-element {
height: 320px;

background: linear-gradient(red, yellow, blue);
}
.multiple {
border: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ _ = Cypress._
Promise = Cypress.Promise
Screenshot = Cypress.Screenshot

getViewportHeight = () ->
Math.min(cy.state("viewportHeight"), $(cy.state("window")).height())

describe "src/cy/commands/screenshot", ->
beforeEach ->
cy.stub(Cypress, "automation").callThrough()
Expand Down Expand Up @@ -132,7 +135,7 @@ describe "src/cy/commands/screenshot", ->
.then ->
expect(Cypress.automation).to.be.calledWith("take:screenshot")
args = Cypress.automation.withArgs("take:screenshot").args[0][1]
args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime")
args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime")
expect(args).to.eql({
testId: runnable.id
titles: [
Expand Down Expand Up @@ -168,7 +171,7 @@ describe "src/cy/commands/screenshot", ->
.then ->
expect(Cypress.automation.withArgs("take:screenshot")).to.be.calledOnce
args = Cypress.automation.withArgs("take:screenshot").args[0][1]
args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime")
args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime")
expect(args).to.eql({
testId: runnable.id
titles: [
Expand Down Expand Up @@ -201,7 +204,7 @@ describe "src/cy/commands/screenshot", ->
.then ->
expect(Cypress.automation).to.be.calledWith("take:screenshot")
args = Cypress.automation.withArgs("take:screenshot").args[0][1]
args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime")
args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime")
expect(args).to.eql({
testId: runnable.id
titles: [
Expand Down Expand Up @@ -516,18 +519,71 @@ describe "src/cy/commands/screenshot", ->
expect(scrollTo.getCall(2).args.join(",")).to.equal("0,100")

it "sends the right clip values for elements that need scrolling", ->
scrollTo = cy.spy(cy.state("window"), "scrollTo")

cy.get(".tall-element").screenshot()
.then ->
expect(scrollTo.getCall(0).args).to.eql([0, 140])

take = Cypress.automation.withArgs("take:screenshot")
expect(take.args[0][1].clip).to.eql({ x: 20, y: 0, width: 560, height: 200 })
expect(take.args[1][1].clip).to.eql({ x: 20, y: 60, width: 560, height: 120 })

it "sends the right clip values for elements that don't need scrolling", ->
scrollTo = cy.spy(cy.state("window"), "scrollTo")

cy.get(".short-element").screenshot()
.then ->
# even though we don't need to scroll, the implementation behaviour is to
# try to scroll until the element is at the top of the viewport.
expect(scrollTo.getCall(0).args).to.eql([0, 20])

take = Cypress.automation.withArgs("take:screenshot")
expect(take.args[0][1].clip).to.eql({ x: 40, y: 0, width: 200, height: 100 })

it "applies padding to clip values for elements that need scrolling", ->
padding = 10

scrollTo = cy.spy(cy.state("window"), "scrollTo")

cy.get(".tall-element").screenshot({ padding })
.then ->
viewportHeight = getViewportHeight()
expect(scrollTo.getCall(0).args).to.eql([0, 140 - padding])
expect(scrollTo.getCall(1).args).to.eql([0, 140 + viewportHeight - padding ])

take = Cypress.automation.withArgs("take:screenshot")

expect(take.args[0][1].clip).to.eql({
x: 20 - padding,
y: 0,
width: 560 + padding * 2,
height: viewportHeight
})
expect(take.args[1][1].clip).to.eql({
x: 20 - padding,
y: 60 - padding,
width: 560 + padding * 2,
height: 120 + padding * 2
})

it "applies padding to clip values for elements that don't need scrolling", ->
padding = 10

scrollTo = cy.spy(cy.state("window"), "scrollTo")

cy.get(".short-element").screenshot({ padding })
.then ->
expect(scrollTo.getCall(0).args).to.eql([0, padding])

take = Cypress.automation.withArgs("take:screenshot")
expect(take.args[0][1].clip).to.eql({
x: 30,
y: 0,
width: 220,
height: 120
})

it "works with cy.within()", ->
cy.get(".short-element").within ->
cy.screenshot()
Expand Down Expand Up @@ -652,20 +708,37 @@ describe "src/cy/commands/screenshot", ->
cy.visit("/fixtures/screenshots.html")
cy.get('.empty-element').screenshot()

it "throws if padding is not a number", (done) ->
@assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: 50px", done)
cy.screenshot({ padding: '50px' })

it "throws if padding is not an array of numbers", (done) ->
@assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: bad, bad, bad, bad", done)
cy.screenshot({ padding: ['bad', 'bad', 'bad', 'bad'] })

it "throws if padding is not an array with a length between 1 and 4", (done) ->
@assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: 20, 10, 20, 10, 50", done)
cy.screenshot({ padding: [20, 10, 20, 10, 50] })

it "throws if padding is a large negative number that causes a 0px tall element height", (done) ->
@assertErrorMessage("It was not possible to take a screenshot, since the number of scrolls calculated to do so was zero.", done)
cy.visit("/fixtures/screenshots.html")
cy.get('.tall-element').screenshot({ padding: -161 })

it "throws if clip is not an object", (done) ->
@assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: true", done)
@assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: true", done)
cy.screenshot({ clip: true })

it "throws if clip is lacking proper keys", (done) ->
@assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: {x: 5}", done)
@assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: {x: 5}", done)
cy.screenshot({ clip: { x: 5 } })

it "throws if clip has extraneous keys", (done) ->
@assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{5}", done)
@assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{5}", done)
cy.screenshot({ clip: { width: 100, height: 100, x: 5, y: 5, foo: 10 } })

it "throws if clip has non-number values", (done) ->
@assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{4}", done)
@assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{4}", done)
cy.screenshot({ clip: { width: 100, height: 100, x: 5, y: "5" } })

it "throws if element capture with multiple elements", (done) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,31 @@ describe "src/cypress/screenshot", ->
Screenshot.defaults({
clip: { width: 200, height: 100, x: 0, y: 0 }
})
expect(Screenshot.getConfig().clip).to.eql({ width: 200, height: 100, x: 0, y:0 })
expect(
Screenshot.getConfig().clip
).to.eql(
{ width: 200, height: 100, x: 0, y:0 }
)

it "sets and normalizes padding if specified", ->
tests = [
[ 50, [50, 50, 50, 50] ]
[ [15], [15, 15, 15, 15] ]
[ [30, 20], [30, 20, 30, 20] ]
[ [10, 20, 30], [10, 20, 30, 20] ]
[ [20, 10, 20, 10], [20, 10, 20, 10] ]
]

for test in tests
[ input, expected ] = test
Screenshot.defaults({
padding: input
})
expect(
Screenshot.getConfig().padding
).to.eql(
expected
)

it "sets onBeforeScreenshot if specified", ->
onBeforeScreenshot = cy.stub()
Expand Down Expand Up @@ -114,25 +138,31 @@ describe "src/cypress/screenshot", ->
Screenshot.defaults({ blackout: [true] })
.to.throw("Cypress.Screenshot.defaults() 'blackout' option must be an array of strings. You passed: true")

it "throws if clip is not an object", ->
it "throws if padding is not a number or an array of numbers with a length between 1 and 4", ->
expect =>
Screenshot.defaults({ clip: true })
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: true")
Screenshot.defaults({ padding: '50px' })
.to.throw("Cypress.Screenshot.defaults() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: 50px")
expect =>
Screenshot.defaults({ padding: ['bad', 'bad', 'bad', 'bad'] })
.to.throw("Cypress.Screenshot.defaults() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: bad, bad, bad, bad")
expect =>
Screenshot.defaults({ padding: [20, 10, 20, 10, 50] })
.to.throw("Cypress.Screenshot.defaults() 'padding' option must be either a number or an array of numbers with a length between 1 and 4. You passed: 20, 10, 20, 10, 50")

it "throws if clip is lacking proper keys", ->
expect =>
Screenshot.defaults({ clip: { x: 5 } })
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: {x: 5}")
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: {x: 5}")

it "throws if clip has extraneous keys", ->
expect =>
Screenshot.defaults({ clip: { width: 100, height: 100, x: 5, y: 5, foo: 10 } })
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{5}")
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{5}")

it "throws if clip has non-number values", ->
expect =>
Screenshot.defaults({ clip: { width: 100, height: 100, x: 5, y: "5" } })
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{4}")
.to.throw("Cypress.Screenshot.defaults() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{4}")

it "throws if onBeforeScreenshot is not a function", ->
expect =>
Expand Down

0 comments on commit 086e37c

Please sign in to comment.