Add padding support to element screenshot (#4440) (#5078)

* Handle 0px tall elements with a better error message

Closes #5149

Co-authored-by: Minh Nguyen <minhnguyenxx@gmail.com>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>

* Add padding support to element#screenshot

Closes #4440

Co-authored-by: Minh Nguyen <minhnguyenxx@gmail.com>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>

* add e2e tests
This commit is contained in:
Seb Insua
2019-10-11 19:52:00 +01:00
committed by Jennifer Shehane
parent 2944be19a0
commit 116a634a90
9 changed files with 269 additions and 24 deletions
+8
View File
@@ -2189,11 +2189,19 @@ declare namespace Cypress {
height: number
}
type Padding =
| number
| [number]
| [number, number]
| [number, number, number]
| [number, number, number, number]
interface ScreenshotOptions {
blackout: string[]
capture: 'runner' | 'viewport' | 'fullPage'
clip: Dimensions
disableTimersAndAnimations: boolean
padding: Padding
scale: boolean
beforeScreenshot(doc: Document): void
afterScreenshot(doc: Document): void
@@ -81,6 +81,12 @@ scrollOverrides = (win, doc) ->
doc.body.style.overflowY = originalBodyOverflowY
win.scrollTo(originalX, originalY)
validateNumScreenshots = (numScreenshots, automationOptions) ->
if numScreenshots < 1
$utils.throwErrByPath("screenshot.invalid_height", {
log: automationOptions.log
})
takeScrollingScreenshots = (scrolls, win, state, automationOptions) ->
scrollAndTake = ({ y, clip, afterScroll }, index) ->
win.scrollTo(0, y)
@@ -108,6 +114,8 @@ takeFullPageScreenshot = (state, automationOptions) ->
viewportHeight = getViewportHeight(state)
numScreenshots = Math.ceil(docHeight / viewportHeight)
validateNumScreenshots(numScreenshots, automationOptions)
scrolls = _.map _.times(numScreenshots), (index) ->
y = viewportHeight * index
clip = if index + 1 is numScreenshots
@@ -126,21 +134,48 @@ takeFullPageScreenshot = (state, automationOptions) ->
takeScrollingScreenshots(scrolls, win, state, automationOptions)
.finally(resetScrollOverrides)
applyPaddingToElementPositioning = (elPosition, automationOptions) ->
if not automationOptions.padding
return elPosition
[ paddingTop, paddingRight, paddingBottom, paddingLeft ] = automationOptions.padding
return {
width: elPosition.width + paddingLeft + paddingRight
height: elPosition.height + paddingTop + paddingBottom
fromViewport: {
top: elPosition.fromViewport.top - paddingTop
left: elPosition.fromViewport.left - paddingLeft
bottom: elPosition.fromViewport.bottom + paddingBottom
}
fromWindow: {
top: elPosition.fromWindow.top - paddingTop
}
}
takeElementScreenshot = ($el, state, automationOptions) ->
win = state("window")
doc = state("document")
resetScrollOverrides = scrollOverrides(win, doc)
elPosition = $dom.getElementPositioning($el)
elPosition = applyPaddingToElementPositioning(
$dom.getElementPositioning($el),
automationOptions
)
viewportHeight = getViewportHeight(state)
viewportWidth = getViewportWidth(state)
numScreenshots = Math.ceil(elPosition.height / viewportHeight)
validateNumScreenshots(numScreenshots, automationOptions)
scrolls = _.map _.times(numScreenshots), (index) ->
y = elPosition.fromWindow.top + (viewportHeight * index)
afterScroll = ->
elPosition = $dom.getElementPositioning($el)
elPosition = applyPaddingToElementPositioning(
$dom.getElementPositioning($el),
automationOptions
)
x = Math.min(viewportWidth, elPosition.fromViewport.left)
width = Math.min(viewportWidth - x, elPosition.width)
@@ -188,6 +223,7 @@ getBlackout = ({ capture, blackout }) ->
takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
{
capture
padding
clip
disableTimersAndAnimations
onBeforeScreenshot
@@ -236,6 +272,7 @@ takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
width: getViewportWidth(state)
height: getViewportHeight(state)
}
padding
userClip: clip
viewport: {
width: window.innerWidth
@@ -313,7 +350,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)
@@ -780,7 +780,9 @@ 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_height: "#{cmd('screenshot')} only works with a screenshot area with a height greater than zero."
invalid_padding: "{{cmd}}() 'padding' option must be either a number or an array of numbers with a maximum length of 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."
@@ -16,6 +16,37 @@ defaults = reset()
validCaptures = ["fullPage", "viewport", "runner"]
normalizePadding = (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 = padding
return [
top
right
bottom
left
]
validateAndSetBoolean = (props, values, cmd, log, option) ->
value = props[option]
if not value?
@@ -94,6 +125,21 @@ validate = (props, cmd, log) ->
values.clip = clip
if padding = props.padding
isShorthandPadding = (value) -> (
(_.isArray(value) and
value.length >= 1 and
value.length <= 4 and
_.every(value, _.isFinite))
)
if not (_.isFinite(padding) or isShorthandPadding(padding))
$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")
@@ -13,6 +13,12 @@
border: solid 1px black;
margin: 20px;
}
.empty-element {
height: 0px;
width: 0px;
border: 0px;
margin: 0px;
}
.short-element {
height: 100px;
margin-left: 40px;
@@ -20,6 +26,8 @@
}
.tall-element {
height: 320px;
background: linear-gradient(red, yellow, blue);
}
.multiple {
border: none;
@@ -28,6 +36,7 @@
</style>
</head>
<body>
<div class="empty-element"></div>
<div class="short-element"></div>
<div class="tall-element"></div>
<div class="multiple"></div>
@@ -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()
@@ -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: [
@@ -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: [
@@ -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: [
@@ -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()
@@ -647,20 +703,42 @@ describe "src/cy/commands/screenshot", ->
@assertErrorMessage("cy.screenshot() 'blackout' option must be an array of strings. You passed: true", done)
cy.screenshot({ blackout: [true] })
it "throws if there is a 0px tall element height", (done) ->
@assertErrorMessage("cy.screenshot() only works with a screenshot area with a height greater than zero.", done)
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 maximum length of 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 maximum length of 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 maximum length of 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("cy.screenshot() only works with a screenshot area with a height greater than 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) ->
@@ -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()
@@ -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 maximum length of 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 maximum length of 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 maximum length of 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 =>
@@ -33,6 +33,8 @@ exports['e2e screenshots passes 1'] = `
2) ensures unique paths when there's a non-named screenshot and a failure
✓ properly resizes the AUT iframe
- does not take a screenshot for a pending test
✓ adds padding to element screenshot when specified
✓ does not add padding to non-element screenshot
clipping
✓ can clip app screenshots
✓ can clip runner screenshots
@@ -48,7 +50,7 @@ exports['e2e screenshots passes 1'] = `
✓ takes another screenshot
18 passing
20 passing
1 pending
5 failing
@@ -84,12 +86,12 @@ Because this error occurred during a 'after each' hook we are skipping the remai
(Results)
┌───────────────────────────────────┐
│ Tests: 23
│ Passing: 18
│ Tests: 25
│ Passing: 20
│ Failing: 4 │
│ Pending: 1 │
│ Skipped: 0 │
│ Screenshots: 26
│ Screenshots: 28
│ Video: true │
│ Duration: X seconds │
│ Spec Ran: screenshots_spec.js │
@@ -115,6 +117,8 @@ Because this error occurred during a 'after each' hook we are skipping the remai
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/taking screenshots -- ensures unique paths when there's a non-named screenshot and a failure.png (1000x660)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/taking screenshots -- ensures unique paths when there's a non-named screenshot and a failure (failed).png (1280x720)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/aut-resize.png (1000x2000)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/element-padding.png (420x320)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/non-element-padding.png (600x200)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/app-clip.png (100x50)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/runner-clip.png (120x60)
- /foo/bar/.projects/e2e/cypress/screenshots/screenshots_spec.js/fullPage-clip.png (140x70)
@@ -139,9 +143,9 @@ Because this error occurred during a 'after each' hook we are skipping the remai
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✖ screenshots_spec.js XX:XX 23 18 4 1 - │
│ ✖ screenshots_spec.js XX:XX 25 20 4 1 - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
1 of 1 failed (100%) XX:XX 23 18 4 1 -
1 of 1 failed (100%) XX:XX 25 20 4 1 -
`
@@ -264,6 +264,37 @@ describe('taking screenshots', () => {
this.skip()
})
it('adds padding to element screenshot when specified', () => {
cy.visit('http://localhost:3322/element')
cy.get('.element')
.screenshot('element-padding', {
padding: 10,
})
cy.task('check:screenshot:size', {
name: `${path.basename(__filename)}/element-padding.png`,
width: 420,
height: 320,
devicePixelRatio,
})
})
it('does not add padding to non-element screenshot', () => {
cy.viewport(600, 200)
cy.visit('http://localhost:3322/color/yellow')
cy.screenshot('non-element-padding', {
capture: 'viewport',
padding: 10,
})
cy.task('check:screenshot:size', {
name: `${path.basename(__filename)}/non-element-padding.png`,
width: 600,
height: 200,
devicePixelRatio,
})
})
context('before hooks', () => {
before(() => {
// failure 2