Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
sebinsua marked this conversation as resolved.
Outdated
scale: boolean
beforeScreenshot(doc: Document): void
afterScreenshot(doc: Document): void
Expand Down
43 changes: 40 additions & 3 deletions packages/driver/src/cy/commands/screenshot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -188,6 +223,7 @@ getBlackout = ({ capture, blackout }) ->
takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
{
capture
padding
clip
disableTimersAndAnimations
onBeforeScreenshot
Expand Down Expand Up @@ -236,6 +272,7 @@ takeScreenshot = (Cypress, state, screenshotConfig, options = {}) ->
width: getViewportWidth(state)
height: getViewportHeight(state)
}
padding
userClip: clip
viewport: {
width: window.innerWidth
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion packages/driver/src/cypress/error_messages.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
46 changes: 46 additions & 0 deletions packages/driver/src/cypress/screenshot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
sebinsua marked this conversation as resolved.
Outdated
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?
Expand Down Expand Up @@ -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")

Expand Down
9 changes: 9 additions & 0 deletions packages/driver/test/cypress/fixtures/screenshots.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@
border: solid 1px black;
margin: 20px;
}
.empty-element {
height: 0px;
width: 0px;
border: 0px;
margin: 0px;
}
.short-element {
height: 100px;
margin-left: 40px;
width: 200px;
}
.tall-element {
height: 320px;

background: linear-gradient(red, yellow, blue);
}
.multiple {
border: none;
Expand All @@ -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>
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 @@ -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) ->
Expand Down
Loading