diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 02d7a98a2b87..9104095bbdec 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -2170,11 +2170,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 diff --git a/packages/driver/src/cy/commands/screenshot.coffee b/packages/driver/src/cy/commands/screenshot.coffee index f04dd4ca247e..24e4cb77b150 100644 --- a/packages/driver/src/cy/commands/screenshot.coffee +++ b/packages/driver/src/cy/commands/screenshot.coffee @@ -134,13 +134,35 @@ 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) @@ -150,7 +172,10 @@ takeElementScreenshot = ($el, state, 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) @@ -198,6 +223,7 @@ getBlackout = ({ capture, blackout }) -> takeScreenshot = (Cypress, state, screenshotConfig, options = {}) -> { capture + padding clip disableTimersAndAnimations onBeforeScreenshot @@ -246,6 +272,7 @@ takeScreenshot = (Cypress, state, screenshotConfig, options = {}) -> width: getViewportWidth(state) height: getViewportHeight(state) } + padding userClip: clip viewport: { width: window.innerWidth @@ -323,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) diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index c94408a90b1b..177919dd7fab 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -773,8 +773,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." diff --git a/packages/driver/src/cypress/screenshot.coffee b/packages/driver/src/cypress/screenshot.coffee index 541523b5223d..5e78cb0dbb33 100644 --- a/packages/driver/src/cypress/screenshot.coffee +++ b/packages/driver/src/cypress/screenshot.coffee @@ -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") diff --git a/packages/driver/test/cypress/fixtures/screenshots.html b/packages/driver/test/cypress/fixtures/screenshots.html index cf35d967d29c..50cd98fc549d 100644 --- a/packages/driver/test/cypress/fixtures/screenshots.html +++ b/packages/driver/test/cypress/fixtures/screenshots.html @@ -26,6 +26,8 @@ } .tall-element { height: 320px; + + background: linear-gradient(red, yellow, blue); } .multiple { border: none; diff --git a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee index f90f7fa2d91e..1bb6c2dc7479 100644 --- a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee @@ -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() @@ -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 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) -> diff --git a/packages/driver/test/cypress/integration/cypress/screenshot_spec.coffee b/packages/driver/test/cypress/integration/cypress/screenshot_spec.coffee index 69d277ba1747..5723acd6392c 100644 --- a/packages/driver/test/cypress/integration/cypress/screenshot_spec.coffee +++ b/packages/driver/test/cypress/integration/cypress/screenshot_spec.coffee @@ -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 =>