mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-12 02:00:06 -06:00
519 lines
14 KiB
JavaScript
519 lines
14 KiB
JavaScript
const _ = require('lodash')
|
|
const mime = require('mime')
|
|
const path = require('path')
|
|
const Promise = require('bluebird')
|
|
const dataUriToBuffer = require('data-uri-to-buffer')
|
|
const Jimp = require('jimp')
|
|
const sizeOf = require('image-size')
|
|
const colorString = require('color-string')
|
|
const sanitize = require('sanitize-filename')
|
|
let debug = require('debug')('cypress:server:screenshot')
|
|
const plugins = require('./plugins')
|
|
const { fs } = require('./util/fs')
|
|
|
|
const RUNNABLE_SEPARATOR = ' -- '
|
|
const pathSeparatorRe = /[\\\/]/g
|
|
|
|
// internal id incrementor
|
|
let __ID__ = null
|
|
|
|
// many filesystems limit filename length to 255 bytes/characters, so truncate the filename to
|
|
// the smallest common denominator of safe filenames, which is 255 bytes. when ENAMETOOLONG
|
|
// errors are encountered, `maxSafeBytes` will be decremented to at most `MIN_PREFIX_BYTES`, at
|
|
// which point the latest ENAMETOOLONG error will be emitted.
|
|
// @see https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
|
|
let maxSafeBytes = Number(process.env.CYPRESS_MAX_SAFE_FILENAME_BYTES) || 254
|
|
const MIN_PREFIX_BYTES = 64
|
|
|
|
// TODO: when we parallelize these builds we'll need
|
|
// a semaphore to access the file system when we write
|
|
// screenshots since its possible two screenshots with
|
|
// the same name will be written to the file system
|
|
|
|
// when debugging logs automatically prefix the
|
|
// screenshot id to the debug logs for easier association
|
|
debug = _.wrap(debug, (fn, str, ...args) => {
|
|
return fn(`(${__ID__}) ${str}`, ...args)
|
|
})
|
|
|
|
const isBlack = (rgba) => {
|
|
return `${rgba.r}${rgba.g}${rgba.b}` === '000'
|
|
}
|
|
|
|
const isWhite = (rgba) => {
|
|
return `${rgba.r}${rgba.g}${rgba.b}` === '255255255'
|
|
}
|
|
|
|
const intToRGBA = function (int) {
|
|
const obj = Jimp.intToRGBA(int)
|
|
|
|
if (debug.enabled) {
|
|
obj.name = colorString.to.keyword([
|
|
obj.r,
|
|
obj.g,
|
|
obj.b,
|
|
])
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
// when we hide the runner UI for an app or fullPage capture
|
|
// the browser doesn't paint synchronously, it can take 100+ ms
|
|
// to ensure that the runner UI has been hidden, we put
|
|
// pixels in the corners of the runner UI like so:
|
|
//
|
|
// -------------
|
|
// |g w w| w = white
|
|
// |w | g = grey
|
|
// | | b = black
|
|
// |w b|
|
|
// -------------
|
|
//
|
|
// when taking an 'app' or 'fullPage' capture, we ensure that the pixels
|
|
// are NOT there before accepting the screenshot
|
|
// when taking a 'runner' capture, we ensure the pixels ARE there
|
|
|
|
const hasHelperPixels = function (image, pixelRatio) {
|
|
const topLeft = intToRGBA(image.getPixelColor(0, 0))
|
|
const topLeftRight = intToRGBA(image.getPixelColor(1 * pixelRatio, 0))
|
|
const topLeftDown = intToRGBA(image.getPixelColor(0, 1 * pixelRatio))
|
|
const bottomLeft = intToRGBA(image.getPixelColor(0, image.bitmap.height))
|
|
const topRight = intToRGBA(image.getPixelColor(image.bitmap.width, 0))
|
|
const bottomRight = intToRGBA(image.getPixelColor(image.bitmap.width, image.bitmap.height))
|
|
|
|
topLeft.isNotWhite = !isWhite(topLeft)
|
|
topLeftRight.isWhite = isWhite(topLeftRight)
|
|
topLeftDown.isWhite = isWhite(topLeftDown)
|
|
bottomLeft.isWhite = isWhite(bottomLeft)
|
|
topRight.isWhite = isWhite(topRight)
|
|
bottomRight.isBlack = isBlack(bottomRight)
|
|
|
|
debug('helper pixels \n %O', {
|
|
topLeft,
|
|
topLeftRight,
|
|
topLeftDown,
|
|
bottomLeft,
|
|
topRight,
|
|
bottomRight,
|
|
})
|
|
|
|
return (
|
|
topLeft.isNotWhite &&
|
|
topLeftRight.isWhite &&
|
|
topLeftDown.isWhite &&
|
|
bottomLeft.isWhite &&
|
|
topRight.isWhite &&
|
|
bottomRight.isBlack
|
|
)
|
|
}
|
|
|
|
const captureAndCheck = function (data, automate, conditionFn) {
|
|
let attempt
|
|
const start = new Date()
|
|
let tries = 0
|
|
|
|
return (attempt = function () {
|
|
tries++
|
|
const totalDuration = new Date() - start
|
|
|
|
debug('capture and check %o', { tries, totalDuration })
|
|
|
|
const takenAt = new Date().toJSON()
|
|
|
|
return automate(data)
|
|
.then((dataUrl) => {
|
|
debug('received screenshot data from automation layer', dataUrl.slice(0, 100))
|
|
|
|
return Jimp.read(dataUriToBuffer(dataUrl))
|
|
}).then((image) => {
|
|
debug(`read buffer to image ${image.bitmap.width} x ${image.bitmap.height}`)
|
|
|
|
if ((totalDuration > 1500) || conditionFn(data, image)) {
|
|
debug('resolving with image %o', { tries, totalDuration })
|
|
|
|
return { image, takenAt }
|
|
}
|
|
|
|
return attempt()
|
|
})
|
|
})()
|
|
}
|
|
|
|
const isAppOnly = (data) => {
|
|
return (data.capture === 'viewport') || (data.capture === 'fullPage')
|
|
}
|
|
|
|
const isMultipart = (data) => {
|
|
return _.isNumber(data.current) && _.isNumber(data.total)
|
|
}
|
|
|
|
const crop = function (image, dimensions, pixelRatio = 1) {
|
|
debug('dimensions before are %o', dimensions)
|
|
|
|
dimensions = _.transform(dimensions, (result, value, dimension) => {
|
|
return result[dimension] = value * pixelRatio
|
|
})
|
|
|
|
debug('dimensions for cropping are %o', dimensions)
|
|
|
|
// Dimensions x/y can sometimes return negative numbers
|
|
// https://github.com/cypress-io/cypress/issues/2034
|
|
const x = Math.max(0, Math.min(dimensions.x, image.bitmap.width - 1))
|
|
const y = Math.max(0, Math.min(dimensions.y, image.bitmap.height - 1))
|
|
const width = Math.min(dimensions.width, image.bitmap.width - x)
|
|
const height = Math.min(dimensions.height, image.bitmap.height - y)
|
|
|
|
debug(`crop: from ${x}, ${y}`)
|
|
debug(` to ${width} x ${height}`)
|
|
|
|
return image.clone().crop(x, y, width, height)
|
|
}
|
|
|
|
const pixelConditionFn = function (data, image) {
|
|
const pixelRatio = image.bitmap.width / data.viewport.width
|
|
|
|
const hasPixels = hasHelperPixels(image, pixelRatio)
|
|
const app = isAppOnly(data)
|
|
|
|
const subject = app ? 'app' : 'runner'
|
|
|
|
// if we are app, we dont need helper pixels else we do!
|
|
const passes = app ? !hasPixels : hasPixels
|
|
|
|
debug('pixelConditionFn %o', {
|
|
pixelRatio,
|
|
subject,
|
|
hasPixels,
|
|
expectedPixels: !app,
|
|
})
|
|
|
|
return passes
|
|
}
|
|
|
|
let multipartImages = []
|
|
|
|
const clearMultipartState = function () {
|
|
debug('clearing %d cached multipart images', multipartImages.length)
|
|
multipartImages = []
|
|
}
|
|
|
|
const imagesMatch = (img1, img2) => {
|
|
// using Buffer::equals here
|
|
return img1.bitmap.data.equals(img2.bitmap.data)
|
|
}
|
|
|
|
const lastImagesAreDifferent = function (data, image) {
|
|
// ensure the previous image isn't the same,
|
|
// which might indicate the page has not scrolled yet
|
|
const previous = _.last(multipartImages)
|
|
|
|
if (!previous) {
|
|
debug('no previous image to compare')
|
|
|
|
return true
|
|
}
|
|
|
|
const matches = imagesMatch(previous.image, image)
|
|
|
|
debug('comparing previous and current image pixels %o', {
|
|
previous: previous.__ID__,
|
|
matches,
|
|
})
|
|
|
|
// return whether or not the two images match
|
|
// should be true if they don't, false if they do
|
|
return !matches
|
|
}
|
|
|
|
const multipartConditionFn = function (data, image) {
|
|
if (data.current === 1) {
|
|
return pixelConditionFn(data, image) && lastImagesAreDifferent(data, image)
|
|
}
|
|
|
|
return lastImagesAreDifferent(data, image)
|
|
}
|
|
|
|
const stitchScreenshots = function (pixelRatio) {
|
|
const fullWidth = _
|
|
.chain(multipartImages)
|
|
.map('data.clip.width')
|
|
.min()
|
|
.multiply(pixelRatio)
|
|
.value()
|
|
|
|
const fullHeight = _
|
|
.chain(multipartImages)
|
|
.sumBy('data.clip.height')
|
|
.multiply(pixelRatio)
|
|
.value()
|
|
|
|
debug(`stitch ${multipartImages.length} images together`)
|
|
|
|
const takenAts = []
|
|
let heightMarker = 0
|
|
const fullImage = new Jimp(fullWidth, fullHeight)
|
|
|
|
_.each(multipartImages, ({ data, image, takenAt }) => {
|
|
const croppedImage = crop(image, data.clip, pixelRatio)
|
|
|
|
debug(`stitch: add image at (0, ${heightMarker})`)
|
|
|
|
takenAts.push(takenAt)
|
|
fullImage.composite(croppedImage, 0, heightMarker)
|
|
heightMarker += croppedImage.bitmap.height
|
|
})
|
|
|
|
return { image: fullImage, takenAt: takenAts }
|
|
}
|
|
|
|
const getType = function (details) {
|
|
if (details.buffer) {
|
|
return details.buffer.type
|
|
}
|
|
|
|
return details.image.getMIME()
|
|
}
|
|
|
|
const getBuffer = function (details) {
|
|
if (details.buffer) {
|
|
return Promise.resolve(details.buffer)
|
|
}
|
|
|
|
return Promise
|
|
.promisify(details.image.getBuffer)
|
|
.call(details.image, Jimp.AUTO)
|
|
}
|
|
|
|
const getDimensions = function (details) {
|
|
const pick = (obj) => {
|
|
return _.pick(obj, 'width', 'height')
|
|
}
|
|
|
|
if (details.buffer) {
|
|
return pick(sizeOf(details.buffer))
|
|
}
|
|
|
|
return pick(details.image.bitmap)
|
|
}
|
|
|
|
const ensureSafePath = function (withoutExt, extension, overwrite, num = 0) {
|
|
const suffix = `${(num && !overwrite) ? ` (${num})` : ''}.${extension}`
|
|
|
|
const maxSafePrefixBytes = maxSafeBytes - suffix.length
|
|
const filenameBuf = Buffer.from(path.basename(withoutExt))
|
|
|
|
if (filenameBuf.byteLength > maxSafePrefixBytes) {
|
|
const truncated = filenameBuf.slice(0, maxSafePrefixBytes).toString()
|
|
|
|
withoutExt = path.join(path.dirname(withoutExt), truncated)
|
|
}
|
|
|
|
const fullPath = [withoutExt, suffix].join('')
|
|
|
|
debug('ensureSafePath %o', { withoutExt, extension, num, maxSafeBytes, maxSafePrefixBytes })
|
|
|
|
return fs.pathExists(fullPath)
|
|
.then((found) => {
|
|
if (found && !overwrite) {
|
|
return ensureSafePath(withoutExt, extension, overwrite, num + 1)
|
|
}
|
|
|
|
// path does not exist, attempt to create it to check for an ENAMETOOLONG error
|
|
return fs.outputFileAsync(fullPath, '')
|
|
.then(() => fullPath)
|
|
.catch((err) => {
|
|
debug('received error when testing path %o', { err, fullPath, maxSafePrefixBytes, maxSafeBytes })
|
|
|
|
if (err.code === 'ENAMETOOLONG' && maxSafePrefixBytes >= MIN_PREFIX_BYTES) {
|
|
maxSafeBytes -= 1
|
|
|
|
return ensureSafePath(withoutExt, extension, overwrite, num)
|
|
}
|
|
|
|
throw err
|
|
})
|
|
})
|
|
}
|
|
|
|
const sanitizeToString = (title) => {
|
|
// test titles may be values which aren't strings like
|
|
// null or undefined - so convert before trying to sanitize
|
|
return sanitize(_.toString(title))
|
|
}
|
|
|
|
const getPath = function (data, ext, screenshotsFolder, overwrite) {
|
|
let names
|
|
const specNames = (data.specName || '')
|
|
.split(pathSeparatorRe)
|
|
|
|
if (data.name) {
|
|
names = data.name.split(pathSeparatorRe).map(sanitize)
|
|
} else {
|
|
names = _
|
|
.chain(data.titles)
|
|
.map(sanitizeToString)
|
|
.join(RUNNABLE_SEPARATOR)
|
|
.concat([])
|
|
.value()
|
|
}
|
|
|
|
const index = names.length - 1
|
|
|
|
// append (failed) to the last name
|
|
if (data.testFailure) {
|
|
names[index] = `${names[index]} (failed)`
|
|
}
|
|
|
|
if (data.testAttemptIndex > 0) {
|
|
names[index] = `${names[index]} (attempt ${data.testAttemptIndex + 1})`
|
|
}
|
|
|
|
const withoutExt = path.join(screenshotsFolder, ...specNames, ...names)
|
|
|
|
return ensureSafePath(withoutExt, ext, overwrite)
|
|
}
|
|
|
|
const getPathToScreenshot = function (data, details, screenshotsFolder) {
|
|
const ext = mime.getExtension(getType(details))
|
|
|
|
return getPath(data, ext, screenshotsFolder, data.overwrite)
|
|
}
|
|
|
|
module.exports = {
|
|
crop,
|
|
|
|
getPath,
|
|
|
|
clearMultipartState,
|
|
|
|
imagesMatch,
|
|
|
|
capture (data, automate) {
|
|
__ID__ = _.uniqueId('s')
|
|
|
|
debug('capturing screenshot %o', data)
|
|
|
|
// for failure screenshots, we keep it simple to avoid latency
|
|
// caused by jimp reading the image buffer
|
|
if (data.simple) {
|
|
const takenAt = new Date().toJSON()
|
|
|
|
return automate(data)
|
|
.then((dataUrl) => {
|
|
return {
|
|
takenAt,
|
|
multipart: false,
|
|
buffer: dataUriToBuffer(dataUrl),
|
|
}
|
|
})
|
|
}
|
|
|
|
const multipart = isMultipart(data)
|
|
|
|
const conditionFn = multipart ? multipartConditionFn : pixelConditionFn
|
|
|
|
return captureAndCheck(data, automate, conditionFn)
|
|
.then(({ image, takenAt }) => {
|
|
const pixelRatio = image.bitmap.width / data.viewport.width
|
|
|
|
debug('pixel ratio is', pixelRatio)
|
|
|
|
if (multipart) {
|
|
debug(`multi-part ${data.current}/${data.total}`)
|
|
}
|
|
|
|
if (multipart && (data.total > 1)) {
|
|
// keep previous screenshot partials around b/c if two screenshots are
|
|
// taken in a row, the UI might not be caught up so we need something
|
|
// to compare the new one to
|
|
// only clear out once we're ready to save the first partial for the
|
|
// screenshot currently being taken
|
|
if (data.current === 1) {
|
|
clearMultipartState()
|
|
}
|
|
|
|
debug('storing image for future comparison', __ID__)
|
|
|
|
multipartImages.push({ data, image, takenAt, __ID__ })
|
|
|
|
if (data.current === data.total) {
|
|
({ image } = stitchScreenshots(pixelRatio))
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
if (isAppOnly(data) || isMultipart(data)) {
|
|
image = crop(image, data.clip, pixelRatio)
|
|
}
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
})
|
|
.then(({ image, pixelRatio, multipart, takenAt }) => {
|
|
if (!image) {
|
|
return null
|
|
}
|
|
|
|
if (image && data.userClip) {
|
|
image = crop(image, data.userClip, pixelRatio)
|
|
}
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
})
|
|
},
|
|
|
|
save (data, details, screenshotsFolder) {
|
|
return getPathToScreenshot(data, details, screenshotsFolder)
|
|
.then((pathToScreenshot) => {
|
|
debug('save', pathToScreenshot)
|
|
|
|
return getBuffer(details)
|
|
.then((buffer) => {
|
|
return fs.outputFileAsync(pathToScreenshot, buffer)
|
|
}).then(() => {
|
|
return fs.statAsync(pathToScreenshot).get('size')
|
|
}).then((size) => {
|
|
const dimensions = getDimensions(details)
|
|
|
|
const { multipart, pixelRatio, takenAt } = details
|
|
|
|
return {
|
|
size,
|
|
takenAt,
|
|
dimensions,
|
|
multipart,
|
|
pixelRatio,
|
|
name: data.name,
|
|
specName: data.specName,
|
|
testFailure: data.testFailure,
|
|
path: pathToScreenshot,
|
|
}
|
|
})
|
|
})
|
|
},
|
|
|
|
afterScreenshot (data, details) {
|
|
const duration = new Date() - new Date(data.startTime)
|
|
|
|
details = _.extend({}, data, details, { duration })
|
|
details = _.pick(details, 'testAttemptIndex', 'size', 'takenAt', 'dimensions', 'multipart', 'pixelRatio', 'name', 'specName', 'testFailure', 'path', 'scaled', 'blackout', 'duration')
|
|
|
|
if (!plugins.has('after:screenshot')) {
|
|
return Promise.resolve(details)
|
|
}
|
|
|
|
return plugins.execute('after:screenshot', details)
|
|
.then((updates) => {
|
|
if (!_.isPlainObject(updates)) {
|
|
return details
|
|
}
|
|
|
|
return _.extend(details, _.pick(updates, 'size', 'dimensions', 'path'))
|
|
})
|
|
},
|
|
|
|
}
|