UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

858 lines (704 loc) 27.3 kB
require('../spec_helper') const _ = require('lodash') const path = require('path') const Jimp = require('jimp') const { Buffer } = require('buffer') const dataUriToBuffer = require('data-uri-to-buffer') const sizeOf = require('image-size') const Fixtures = require('../support/helpers/fixtures') const config = require(`${root}lib/config`) const screenshots = require(`${root}lib/screenshots`) const { fs } = require(`${root}lib/util/fs`) const plugins = require(`${root}lib/plugins`) const { Screenshot } = require(`${root}lib/automation/screenshot`) const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAALlJREFUeNpi1F3xYAIDA4MBA35wgQWqyB5dRoaVmeHJ779wPhOM0aQtyBAoyglmOwmwM6z1lWY44CMDFgcBFmRTGp3EGGJe/WIQ5mZm4GRlBGJmhlm3PqGaeODpNzCtKsbGIARUCALvvv6FWw9XeOvrH4bbQNOQwfabnzHdGK3AwyAjyAqX2HPzC0Pn7Y9wPtyNIMGlD74wmAqwMZz+8AvFxzATVZAFQIqwABWQiWtgAY5uCnKAAwQYAPr8OZysiz4PAAAAAElFTkSuQmCC' const iso8601Regex = /^\d{4}\-\d{2}\-\d{2}T\d{2}\:\d{2}\:\d{2}\.?\d*Z?$/ describe('lib/screenshots', () => { beforeEach(function () { // make each test timeout after only 1 sec // so that durations are handled correctly this.currentTest.timeout(1000) Fixtures.scaffold() this.todosPath = Fixtures.projectPath('todos') this.appData = { capture: 'viewport', clip: { x: 0, y: 0, width: 10, height: 10 }, viewport: { width: 40, height: 40 }, } this.buffer = Buffer.from('image 1 data buffer') this.jimpImage = { id: 1, bitmap: { width: 40, height: 40, data: this.buffer, }, crop: sinon.stub().callsFake(() => { return this.jimpImage }), getBuffer: sinon.stub().resolves(this.buffer), getMIME () { return 'image/png' }, hash: sinon.stub().returns('image hash'), clone: () => { return this.jimpImage }, } Jimp.prototype.composite = sinon.stub() // Jimp.prototype.getBuffer = sinon.stub().resolves(@buffer) return config.get(this.todosPath).then((config1) => { this.config = config1 }) }) afterEach(() => { return Fixtures.remove() }) context('.capture', () => { beforeEach(function () { this.getPixelColor = sinon.stub() this.getPixelColor.withArgs(0, 0).returns('grey') this.getPixelColor.withArgs(1, 0).returns('white') this.getPixelColor.withArgs(0, 1).returns('white') this.getPixelColor.withArgs(40, 0).returns('white') this.getPixelColor.withArgs(0, 40).returns('white') this.getPixelColor.withArgs(40, 40).returns('black') this.jimpImage.getPixelColor = this.getPixelColor sinon.stub(Jimp, 'read').resolves(this.jimpImage) const intToRGBA = sinon.stub(Jimp, 'intToRGBA') intToRGBA.withArgs('black').returns({ r: 0, g: 0, b: 0 }) intToRGBA.withArgs('grey').returns({ r: 127, g: 127, b: 127 }) intToRGBA.withArgs('white').returns({ r: 255, g: 255, b: 255 }) this.automate = sinon.stub().resolves(image) this.passPixelTest = () => { return this.getPixelColor.withArgs(0, 0).returns('white') } }) it('captures screenshot with automation', function () { const data = { viewport: this.jimpImage.bitmap } return screenshots.capture(data, this.automate).then(() => { expect(this.automate).to.be.calledOnce expect(this.automate).to.be.calledWith(data) }) }) it('retries until helper pixels are no longer present for viewport capture', function () { this.getPixelColor.withArgs(0, 0).onCall(1).returns('white') return screenshots.capture(this.appData, this.automate).then(() => { expect(this.automate).to.be.calledTwice }) }) it('retries until helper pixels are present for runner capture', function () { this.passPixelTest() this.getPixelColor.withArgs(0, 0).onCall(1).returns('black') return screenshots.capture({ viewport: this.jimpImage.bitmap }, this.automate) .then(() => { expect(this.automate).to.be.calledTwice }) }) it('adjusts cropping based on pixel ratio', function () { this.appData.viewport = { width: 20, height: 20 } this.appData.clip = { x: 5, y: 5, width: 10, height: 10 } this.passPixelTest() this.getPixelColor.withArgs(2, 0).returns('white') this.getPixelColor.withArgs(0, 2).returns('white') return screenshots.capture(this.appData, this.automate) .then(() => { expect(this.jimpImage.crop).to.be.calledWith(10, 10, 20, 20) }) }) it('resolves details w/ image', function () { this.passPixelTest() return screenshots.capture(this.appData, this.automate).then((details) => { expect(details.image).to.equal(this.jimpImage) expect(details.multipart).to.be.false expect(details.pixelRatio).to.equal(1) expect(details.takenAt).to.match(iso8601Regex) }) }) describe('simple capture', () => { beforeEach(function () { this.appData.simple = true }) it('skips pixel checking / reading into Jimp image', function () { return screenshots.capture(this.appData, this.automate).then(() => { expect(Jimp.read).not.to.be.called }) }) it('resolves details w/ buffer', function () { return screenshots.capture(this.appData, this.automate).then((details) => { expect(details.takenAt).to.match(iso8601Regex) expect(details.multipart).to.be.false expect(details.buffer).to.be.instanceOf(Buffer) }) }) }) describe('userClip', () => { it('crops final image if userClip specified', function () { this.appData.userClip = { width: 5, height: 5, x: 2, y: 2 } this.passPixelTest() return screenshots.capture(this.appData, this.automate).then(() => { expect(this.jimpImage.crop).to.be.calledWith(2, 2, 5, 5) }) }) it('does not crop intermediary multi-part images', function () { this.appData.userClip = { width: 5, height: 5, x: 2, y: 2 } this.appData.current = 1 this.appData.total = 3 this.passPixelTest() return screenshots.capture(this.appData, this.automate).then(() => { expect(this.jimpImage.crop).not.to.be.called }) }) it('adjusts cropping based on pixel ratio', function () { this.appData.viewport = { width: 20, height: 20 } this.appData.userClip = { x: 5, y: 5, width: 10, height: 10 } this.passPixelTest() this.getPixelColor.withArgs(2, 0).returns('white') this.getPixelColor.withArgs(0, 2).returns('white') return screenshots.capture(this.appData, this.automate).then(() => { expect(this.jimpImage.crop).to.be.calledWith(10, 10, 20, 20) }) }) }) describe('multi-part capture (fullPage or element)', () => { beforeEach(function () { screenshots.clearMultipartState() this.appData.current = 1 this.appData.total = 3 this.getPixelColor.withArgs(0, 0).onSecondCall().returns('white') const clone = (img, props) => { return _.defaultsDeep(props, img) } this.jimpImage2 = clone(this.jimpImage, { id: 2, bitmap: { data: Buffer.from('image 2 data buffer'), }, }) this.jimpImage3 = clone(this.jimpImage, { id: 3, bitmap: { data: Buffer.from('image 3 data buffer'), }, }) this.jimpImage4 = clone(this.jimpImage, { id: 4, bitmap: { data: Buffer.from('image 4 data buffer'), }, }) }) it('retries until helper pixels are no longer present on first capture', function () { return screenshots.capture(this.appData, this.automate) .then(() => { expect(this.automate).to.be.calledTwice }) }) it('retries until images aren\'t the same on subsequent captures', function () { return screenshots.capture(this.appData, this.automate) .then(() => { Jimp.read.onCall(3).resolves(this.jimpImage2) this.appData.current = 2 return screenshots.capture(this.appData, this.automate) }).then(() => { expect(this.automate.callCount).to.equal(4) }) }) it('resolves no image on non-last captures', function () { return screenshots.capture(this.appData, this.automate) .then((image) => { expect(image).to.be.null }) }) it('resolves details w/ image on last capture', function () { return screenshots.capture(this.appData, this.automate) .then(() => { Jimp.read.onCall(3).resolves(this.jimpImage2) this.appData.current = 3 return screenshots.capture(this.appData, this.automate) }).then(({ image }) => { expect(image).to.be.an.instanceOf(Jimp) }) }) it('composites images into one image', function () { Jimp.read.onThirdCall().resolves(this.jimpImage2) Jimp.read.onCall(3).resolves(this.jimpImage3) return screenshots.capture(this.appData, this.automate) .then(() => { this.appData.current = 2 return screenshots.capture(this.appData, this.automate) }).then(() => { this.appData.current = 3 return screenshots.capture(this.appData, this.automate) }).then(() => { const { composite } = Jimp.prototype expect(composite).to.be.calledThrice expect(composite.getCall(0).args[0]).to.equal(this.jimpImage) expect(composite.getCall(0).args[1]).to.equal(0) expect(composite.getCall(0).args[2]).to.equal(0) expect(composite.getCall(1).args[0]).to.equal(this.jimpImage) expect(composite.getCall(1).args[2]).to.equal(40) expect(composite.getCall(2).args[0]).to.equal(this.jimpImage) expect(composite.getCall(2).args[2]).to.equal(80) }) }) it('clears previous full page state once complete', function () { this.getPixelColor.withArgs(0, 0).returns('white') Jimp.read.onSecondCall().resolves(this.jimpImage2) Jimp.read.onThirdCall().resolves(this.jimpImage3) Jimp.read.onCall(3).resolves(this.jimpImage4) this.appData.total = 2 return screenshots.capture(this.appData, this.automate) .then(() => { this.appData.current = 2 return screenshots.capture(this.appData, this.automate) }).then(() => { this.appData.current = 1 return screenshots.capture(this.appData, this.automate) }).then(() => { this.appData.current = 2 return screenshots.capture(this.appData, this.automate) }).then(() => { expect(Jimp.prototype.composite.callCount).to.equal(4) }) }) it('skips full page process if only one capture needed', function () { this.appData.total = 1 return screenshots.capture(this.appData, this.automate) .then(() => { expect(Jimp.prototype.composite).not.to.be.called }) }) }) describe('integration', () => { beforeEach(function () { screenshots.clearMultipartState() this.currentTest.timeout(10000) sinon.restore() this.data1 = { titles: ['cy.screenshot() - take a screenshot'], testId: 'r2', name: 'app-screenshot', capture: 'fullPage', clip: { x: 0, y: 0, width: 1000, height: 646 }, viewport: { width: 1280, height: 646 }, current: 1, total: 3, } this.data2 = { titles: ['cy.screenshot() - take a screenshot'], testId: 'r2', name: 'app-screenshot', capture: 'fullPage', clip: { x: 0, y: 0, width: 1000, height: 646 }, viewport: { width: 1280, height: 646 }, current: 2, total: 3, } this.data3 = { titles: ['cy.screenshot() - take a screenshot'], testId: 'r2', name: 'app-screenshot', capture: 'fullPage', clip: { x: 0, y: 138, width: 1000, height: 508 }, viewport: { width: 1280, height: 646 }, current: 3, total: 3, } this.dataUri = (img) => { return () => { return fs.readFileAsync(Fixtures.path(`img/${img}`)) .then((buf) => { return `data:image/png;base64,${buf.toString('base64')}` }) } } }) it('stiches together 1x DPI images', function () { return screenshots .capture(this.data1, this.dataUri('DPI-1x/1.png')) .then((img1) => { expect(img1).to.be.null return screenshots .capture(this.data2, this.dataUri('DPI-1x/2.png')) }).then((img2) => { expect(img2).to.be.null return screenshots .capture(this.data3, this.dataUri('DPI-1x/3.png')) }).then((img3) => { return Jimp.read(Fixtures.path('img/DPI-1x/stitched.png')) .then((img) => { expect(screenshots.imagesMatch(img, img3.image)) }) }) }) it('stiches together 2x DPI images', function () { return screenshots .capture(this.data1, this.dataUri('DPI-2x/1.png')) .then((img1) => { expect(img1).to.be.null return screenshots .capture(this.data2, this.dataUri('DPI-2x/2.png')) }).then((img2) => { expect(img2).to.be.null return screenshots .capture(this.data3, this.dataUri('DPI-2x/3.png')) }).then((img3) => { return Jimp.read(Fixtures.path('img/DPI-2x/stitched.png')) .then((img) => { expect(screenshots.imagesMatch(img, img3.image)) }) }) }) }) }) context('.crop', () => { beforeEach(function () { this.dimensions = (overrides) => { return _.extend({ x: 0, y: 0, width: 10, height: 10 }, overrides) } }) it('crops to dimension size if less than the image size', function () { screenshots.crop(this.jimpImage, this.dimensions()) expect(this.jimpImage.crop).to.be.calledWith(0, 0, 10, 10) }) it('crops to dimension size if less than the image size', function () { screenshots.crop(this.jimpImage, this.dimensions()) expect(this.jimpImage.crop).to.be.calledWith(0, 0, 10, 10) }) it('crops to one less than width if dimensions x is more than the image width', function () { screenshots.crop(this.jimpImage, this.dimensions({ x: 50 })) expect(this.jimpImage.crop).to.be.calledWith(39, 0, 1, 10) }) it('crops to one less than height if dimensions y is more than the image height', function () { screenshots.crop(this.jimpImage, this.dimensions({ y: 50 })) expect(this.jimpImage.crop).to.be.calledWith(0, 39, 10, 1) }) it('crops only width if dimensions height is more than the image height', function () { screenshots.crop(this.jimpImage, this.dimensions({ height: 50 })) expect(this.jimpImage.crop).to.be.calledWith(0, 0, 10, 40) }) it('crops only height if dimensions width is more than the image width', function () { screenshots.crop(this.jimpImage, this.dimensions({ width: 50 })) expect(this.jimpImage.crop).to.be.calledWith(0, 0, 40, 10) }) }) context('.save', () => { it('outputs file and returns details', function () { const buf = dataUriToBuffer(image) return Jimp.read(buf) .then((i) => { const details = { image: i, multipart: false, pixelRatio: 2, takenAt: '1234-date', } const dimensions = sizeOf(buf) return screenshots.save( { name: 'foo bar\\baz/my-screenshot', specName: 'foo.spec.js', testFailure: false }, details, this.config.screenshotsFolder, ) .then((result) => { const expectedPath = path.join( this.config.screenshotsFolder, 'foo.spec.js', 'foo bar', 'baz', 'my-screenshot.png', ) const actualPath = path.normalize(result.path) expect(result).to.deep.eq({ multipart: false, pixelRatio: 2, path: path.normalize(result.path), size: 272, name: 'foo bar\\baz/my-screenshot', specName: 'foo.spec.js', testFailure: false, takenAt: '1234-date', dimensions: _.pick(dimensions, 'width', 'height'), }) expect(expectedPath).to.eq(actualPath) return fs.statAsync(expectedPath) }) }) }) it('can handle saving buffer', function () { const details = { multipart: false, pixelRatio: 1, buffer: dataUriToBuffer(image), takenAt: '1234-date', } const dimensions = sizeOf(details.buffer) return screenshots.save( { name: 'with-buffer', specName: 'foo.spec.js', testFailure: false }, details, this.config.screenshotsFolder, ) .then((result) => { const expectedPath = path.join( this.config.screenshotsFolder, 'foo.spec.js', 'with-buffer.png', ) const actualPath = path.normalize(result.path) expect(result).to.deep.eq({ name: 'with-buffer', multipart: false, pixelRatio: 1, path: path.normalize(result.path), size: 279, specName: 'foo.spec.js', testFailure: false, takenAt: '1234-date', dimensions: _.pick(dimensions, 'width', 'height'), }) expect(expectedPath).to.eq(actualPath) return fs.statAsync(expectedPath) }) }) }) context('.copy', () => { it('doesnt yell over ENOENT errors', () => { return screenshots.copy('/does/not/exist', '/foo/bar/baz') }) it('copies src to des with {overwrite: true}', () => { sinon.stub(fs, 'copyAsync').withArgs('foo', 'bar', { overwrite: true }).resolves() return screenshots.copy('foo', 'bar') }) }) context('.getPath', () => { beforeEach(() => { sinon.stub(fs, 'outputFileAsync').resolves() }) it('concats spec name, screenshotsFolder, and name', () => { return screenshots.getPath({ specName: 'examples/user/list.js', titles: ['bar', 'baz'], name: 'quux/lorem', }, 'png', 'path/to/screenshots') .then((p) => { expect(p).to.eq( 'path/to/screenshots/examples/user/list.js/quux/lorem.png', ) }) }) it('concats spec name, screenshotsFolder, and titles', () => { return screenshots.getPath({ specName: 'examples/user/list.js', titles: ['bar', 'baz'], takenPaths: ['a'], testFailure: true, }, 'png', 'path/to/screenshots') .then((p) => { expect(p).to.eq( 'path/to/screenshots/examples/user/list.js/bar -- baz (failed).png', ) }) }) it('sanitizes file paths', () => { return screenshots.getPath({ specName: 'examples$/user/list.js', titles: ['bar*', 'baz..', '語言'], takenPaths: ['a'], testFailure: true, }, 'png', 'path/to/screenshots') .then((p) => { expect(p).to.eq( 'path/to/screenshots/examples$/user/list.js/bar -- baz -- 語言 (failed).png', ) }) }) // @see https://github.com/cypress-io/cypress/issues/2403 it('truncates long paths with unicode in them', async () => { const fullPath = await screenshots.getPath({ titles: [ 'WMED: [STORY] Тестовые сценарии для CI', 'Сценарии:', 'Сценарий 2: Создание обращения, создание медзаписи, привязкапривязка обращения к медзаписи', '- Сценарий 2', ], testFailure: true, specName: 'WMED_UAT_Scenarios_For_CI_spec.js', }, 'png', '/jenkins-slave/workspace/test-wmed/qa/cypress/wmed_ci/cypress/screenshots/') const basename = path.basename(fullPath) expect(Buffer.from(basename).byteLength).to.be.lessThan(255) }) it('reacts to ENAMETOOLONG errors and tries to shorten the filename', async () => { const err = new Error('enametoolong') err.code = 'ENAMETOOLONG' _.times(50, (i) => fs.outputFileAsync.onCall(i).rejects(err)) const fullPath = await screenshots.getPath({ specName: 'foo.js', name: 'a'.repeat(256), }, 'png', '/tmp') expect(path.basename(fullPath)).to.have.length(204) }) it('rejects with ENAMETOOLONG errors if name goes below MIN_PREFIX_LENGTH', async () => { const err = new Error('enametoolong') err.code = 'ENAMETOOLONG' _.times(150, (i) => fs.outputFileAsync.onCall(i).rejects(err)) await expect(screenshots.getPath({ specName: 'foo.js', name: 'a'.repeat(256), }, 'png', '/tmp')).to.be.rejectedWith(err) }) _.each([Infinity, 0 / 0, [], {}, 1, false], (value) => { it(`doesn't err and stringifies non-string test title: ${value}`, () => { return screenshots.getPath({ specName: 'examples$/user/list.js', titles: ['bar*', '語言', value], takenPaths: ['a'], testFailure: true, }, 'png', 'path/to/screenshots') .then((p) => { expect(p).to.eq(`path/to/screenshots/examples$/user/list.js/bar -- 語言 -- ${value} (failed).png`) }) }) }) _.each([null, undefined], (value) => { it(`doesn't err and removes null/undefined test title: ${value}`, () => { return screenshots.getPath({ specName: 'examples$/user/list.js', titles: ['bar*', '語言', value], takenPaths: ['a'], testFailure: true, }, 'png', 'path/to/screenshots') .then((p) => { expect(p).to.eq('path/to/screenshots/examples$/user/list.js/bar -- 語言 -- (failed).png') }) }) }) }) context('.afterScreenshot', () => { beforeEach(function () { this.data = { titles: ['the', 'title'], testId: 'r1', name: 'my-screenshot', capture: 'runner', clip: { x: 0, y: 0, width: 1000, height: 660 }, viewport: { width: 1400, height: 700 }, scaled: true, blackout: [], startTime: '2018-06-27T20:17:19.537Z', specName: 'integration/spec.coffee', } this.details = { size: 100, takenAt: new Date().toISOString(), dimensions: { width: 1000, height: 660 }, multipart: false, pixelRatio: 1, name: 'my-screenshot', specName: 'integration/spec.coffee', testFailure: true, path: '/path/to/my-screenshot.png', } sinon.stub(plugins, 'has') return sinon.stub(plugins, 'execute') }) it('resolves allowed details if no after:screenshot plugin registered', function () { plugins.has.returns(false) return screenshots.afterScreenshot(this.data, this.details).then((result) => { expect(_.omit(result, 'duration')).to.eql({ size: 100, takenAt: this.details.takenAt, dimensions: this.details.dimensions, multipart: false, pixelRatio: 1, name: 'my-screenshot', specName: 'integration/spec.coffee', testFailure: true, path: '/path/to/my-screenshot.png', scaled: true, blackout: [], }) expect(result.duration).to.be.a('number') }) }) it('executes after:screenshot plugin and merges in size, dimensions, and/or path', function () { plugins.has.returns(true) plugins.execute.resolves({ size: 200, dimensions: { width: 2000, height: 1320 }, path: '/new/path/to/screenshot.png', pixelRatio: 2, takenAt: '1234', }) return screenshots.afterScreenshot(this.data, this.details).then((result) => { expect(_.omit(result, 'duration')).to.eql({ size: 200, takenAt: this.details.takenAt, dimensions: { width: 2000, height: 1320 }, multipart: false, pixelRatio: 1, name: 'my-screenshot', specName: 'integration/spec.coffee', testFailure: true, path: '/new/path/to/screenshot.png', scaled: true, blackout: [], }) expect(result.duration).to.be.a('number') }) }) it('ignores updates that are not an object', function () { plugins.execute.resolves('foo') return screenshots.afterScreenshot(this.data, this.details).then((result) => { expect(_.omit(result, 'duration')).to.eql({ size: 100, takenAt: this.details.takenAt, dimensions: this.details.dimensions, multipart: false, pixelRatio: 1, name: 'my-screenshot', specName: 'integration/spec.coffee', testFailure: true, path: '/path/to/my-screenshot.png', scaled: true, blackout: [], }) expect(result.duration).to.be.a('number') }) }) }) }) describe('lib/automation/screenshot', () => { beforeEach(function () { this.details = {} sinon.stub(screenshots, 'capture').resolves(this.details) this.savedDetails = {} sinon.stub(screenshots, 'save').resolves(this.savedDetails) this.updatedDetails = {} sinon.stub(screenshots, 'afterScreenshot').resolves(this.updatedDetails) this.screenshot = Screenshot('cypress/screenshots') }) it('captures screenshot', function () { const data = {} const automation = function () {} return this.screenshot.capture(data, automation).then(() => { expect(screenshots.capture).to.be.calledWith(data, automation) }) }) it('saves screenshot if there\'s a buffer', function () { const data = {} return this.screenshot.capture(data, this.automate).then(() => { expect(screenshots.save).to.be.calledWith(data, this.details, 'cypress/screenshots') }) }) it('does not save screenshot if there\'s no buffer', function () { screenshots.capture.resolves(null) return this.screenshot.capture({}, this.automate).then(() => { expect(screenshots.save).not.to.be.called }) }) it('calls afterScreenshot', function () { const data = {} return this.screenshot.capture(data, this.automate).then(() => { expect(screenshots.afterScreenshot).to.be.calledWith(data, this.savedDetails) }) }) it('resolves with updated details', function () { return this.screenshot.capture({}, this.automate).then((details) => { expect(details).to.equal(this.updatedDetails) }) }) })