mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-03 13:30:26 -05:00
69c221719b
* remove max retries, wait only for 1500ms when taking screenshots * correctly factor in devicePixelRatio in tests, handle electron offscreen rendering incorrectly upscaling screenshots - when electron is running in offscreen headless mode with a retina screen it incorrectly sets devicePixelRatio to one, but still takes a screenshot at 2x ratio, which then upscales the pixels and blurs the pixels, which defeats the pixel checking. - this fix forces electron to render only at a device pixel ratio of 1 when headless. it works normally when headed without having to do anything special - this fixes a bug where pixel coordinate checking was hard coded and was not factoring in device ratio * yield screenshot result props in onAfterScreenshot, pass subject on unchanged
338 lines
9.4 KiB
CoffeeScript
338 lines
9.4 KiB
CoffeeScript
_ = require("lodash")
|
|
mime = require("mime")
|
|
path = require("path")
|
|
bytes = require("bytes")
|
|
Promise = require("bluebird")
|
|
dataUriToBuffer = require("data-uri-to-buffer")
|
|
Jimp = require("jimp")
|
|
sizeOf = require("image-size")
|
|
colorString = require("color-string")
|
|
debug = require("debug")("cypress:server:screenshot")
|
|
fs = require("./util/fs")
|
|
glob = require("./util/glob")
|
|
|
|
RUNNABLE_SEPARATOR = " -- "
|
|
invalidCharsRe = /[^0-9a-zA-Z-_\s]/g
|
|
|
|
## internal id incrementor
|
|
__ID__ = null
|
|
|
|
## 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
|
|
|
|
Jimp.prototype.getBuffer = Promise.promisify(Jimp.prototype.getBuffer)
|
|
|
|
## when debugging logs automatically prefix the
|
|
## screenshot id to the debug logs for easier association
|
|
debug = _.wrap debug, (fn, str, args...) ->
|
|
fn("(#{__ID__}) #{str}", args...)
|
|
|
|
isBlack = (rgba) ->
|
|
"#{rgba.r}#{rgba.g}#{rgba.b}" is "000"
|
|
|
|
isWhite = (rgba) ->
|
|
"#{rgba.r}#{rgba.g}#{rgba.b}" is "255255255"
|
|
|
|
intToRGBA = (int) ->
|
|
obj = Jimp.intToRGBA(int)
|
|
|
|
if debug.enabled
|
|
obj.name = colorString.to.keyword([
|
|
obj.r,
|
|
obj.g,
|
|
obj.b
|
|
])
|
|
|
|
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
|
|
|
|
hasHelperPixels = (image, pixelRatio) ->
|
|
topLeft = intToRGBA(image.getPixelColor(0, 0))
|
|
topLeftRight = intToRGBA(image.getPixelColor(1 * pixelRatio, 0))
|
|
topLeftDown = intToRGBA(image.getPixelColor(0, 1 * pixelRatio))
|
|
bottomLeft = intToRGBA(image.getPixelColor(0, image.bitmap.height))
|
|
topRight = intToRGBA(image.getPixelColor(image.bitmap.width, 0))
|
|
bottomRight = intToRGBA(image.getPixelColor(image.bitmap.width, image.bitmap.height))
|
|
|
|
topLeft.isNotWhite = not 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 %O", {
|
|
topLeft
|
|
topLeftRight
|
|
topLeftDown
|
|
bottomLeft
|
|
topRight
|
|
bottomRight
|
|
})
|
|
|
|
return (
|
|
topLeft.isNotWhite and
|
|
topLeftRight.isWhite and
|
|
topLeftDown.isWhite and
|
|
bottomLeft.isWhite and
|
|
topRight.isWhite and
|
|
bottomRight.isBlack
|
|
)
|
|
|
|
captureAndCheck = (data, automate, conditionFn) ->
|
|
start = new Date()
|
|
tries = 0
|
|
do attempt = ->
|
|
tries++
|
|
totalDuration = new Date() - start
|
|
debug("capture and check %o", { tries, totalDuration })
|
|
|
|
takenAt = new Date().toJSON()
|
|
|
|
automate(data)
|
|
.then (dataUrl) ->
|
|
debug("received screenshot data from automation layer")
|
|
|
|
Jimp.read(dataUriToBuffer(dataUrl))
|
|
.then (image) ->
|
|
debug("read buffer to image #{image.bitmap.width} x #{image.bitmap.height}")
|
|
|
|
if (totalDuration > 1500) or conditionFn(data, image)
|
|
debug("resolving with image %o", { tries, totalDuration })
|
|
return { image, takenAt }
|
|
else
|
|
attempt()
|
|
|
|
isAppOnly = (data) ->
|
|
data.capture is "viewport" or data.capture is "fullPage"
|
|
|
|
isMultipart = (data) ->
|
|
_.isNumber(data.current) and _.isNumber(data.total)
|
|
|
|
crop = (image, dimensions, pixelRatio = 1) ->
|
|
debug("dimensions before are %o", dimensions)
|
|
dimensions = _.transform dimensions, (result, value, dimension) ->
|
|
result[dimension] = value * pixelRatio
|
|
|
|
debug("dimensions for cropping are %o", dimensions)
|
|
x = Math.min(dimensions.x, image.bitmap.width - 1)
|
|
y = Math.min(dimensions.y, image.bitmap.height - 1)
|
|
width = Math.min(dimensions.width, image.bitmap.width - x)
|
|
height = Math.min(dimensions.height, image.bitmap.height - y)
|
|
debug("crop: from #{image.bitmap.width} x #{image.bitmap.height}")
|
|
debug(" to #{width} x #{height} at (#{x}, #{y})")
|
|
|
|
image.crop(x, y, width, height)
|
|
|
|
pixelConditionFn = (data, image) ->
|
|
pixelRatio = image.bitmap.width / data.viewport.width
|
|
|
|
hasPixels = hasHelperPixels(image, pixelRatio)
|
|
app = isAppOnly(data)
|
|
|
|
## if we are app, we dont need helper pixels else we do!
|
|
passes = if app then not hasPixels else hasPixels
|
|
|
|
debug("pixelConditionFn", { pixelRatio, hasPixels, app, passes })
|
|
|
|
return passes
|
|
|
|
multipartImages = []
|
|
|
|
clearMultipartState = ->
|
|
multipartImages = []
|
|
|
|
compareLast = (data, image) ->
|
|
## ensure the previous image isn't the same, which might indicate the
|
|
## page has not scrolled yet
|
|
previous = _.last(multipartImages)
|
|
if not previous
|
|
debug("no previous image to compare")
|
|
return true
|
|
|
|
prevHash = previous.image.hash()
|
|
currHash = image.hash()
|
|
matches = prevHash is currHash
|
|
|
|
debug("comparing previous and current image hashes %o", {
|
|
prevHash
|
|
currHash
|
|
matches
|
|
})
|
|
|
|
return prevHash isnt currHash
|
|
|
|
multipartConditionFn = (data, image) ->
|
|
if data.current is 1
|
|
pixelConditionFn(data, image) and compareLast(data, image)
|
|
else
|
|
compareLast(data, image)
|
|
|
|
stitchScreenshots = (pixelRatio) ->
|
|
width = Math.min(_.map(multipartImages, "data.clip.width")...)
|
|
height = _.sumBy(multipartImages, "data.clip.height")
|
|
|
|
debug("stitch #{multipartImages.length} images together")
|
|
|
|
takenAts = []
|
|
|
|
fullImage = new Jimp(width, height)
|
|
heightMarker = 0
|
|
_.each multipartImages, ({ data, image, takenAt }) ->
|
|
croppedImage = image.clone()
|
|
crop(croppedImage, 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 }
|
|
|
|
isBuffer = (details) ->
|
|
!!details.buffer
|
|
|
|
getType = (details) ->
|
|
if isBuffer(details)
|
|
details.buffer.type
|
|
else
|
|
details.image.getMIME()
|
|
|
|
getBuffer = (details) ->
|
|
if isBuffer(details)
|
|
Promise.resolve(details.buffer)
|
|
else
|
|
details.image.getBuffer(Jimp.AUTO)
|
|
|
|
getDimensions = (details) ->
|
|
if isBuffer(details)
|
|
sizeOf(details.buffer)
|
|
else
|
|
_.pick(details.image.bitmap, "width", "height")
|
|
|
|
module.exports = {
|
|
crop
|
|
|
|
clearMultipartState
|
|
|
|
copy: (src, dest) ->
|
|
fs
|
|
.copyAsync(src, dest, {overwrite: true})
|
|
.catch {code: "ENOENT"}, ->
|
|
## dont yell about ENOENT errors
|
|
|
|
get: (screenshotsFolder) ->
|
|
## find all files in all nested dirs
|
|
screenshotsFolder = path.join(screenshotsFolder, "**", "*")
|
|
|
|
glob(screenshotsFolder, { nodir: true })
|
|
|
|
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
|
|
takenAt = new Date().toJSON()
|
|
return automate(data).then (dataUrl) ->
|
|
{
|
|
takenAt
|
|
multipart: false
|
|
buffer: dataUriToBuffer(dataUrl)
|
|
}
|
|
|
|
multipart = isMultipart(data)
|
|
|
|
conditionFn = if multipart then multipartConditionFn else pixelConditionFn
|
|
|
|
captureAndCheck(data, automate, conditionFn)
|
|
.then ({ image, takenAt }) ->
|
|
pixelRatio = image.bitmap.width / data.viewport.width
|
|
|
|
debug("pixel ratio is", pixelRatio)
|
|
|
|
if multipart
|
|
debug("multi-part #{data.current}/#{data.total}")
|
|
|
|
if multipart and 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 is 1
|
|
clearMultipartState()
|
|
|
|
multipartImages.push({ data, image, takenAt })
|
|
|
|
if data.current is data.total
|
|
{ image } = stitchScreenshots(pixelRatio)
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
else
|
|
return {}
|
|
|
|
if isAppOnly(data) or isMultipart(data)
|
|
crop(image, data.clip, pixelRatio)
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
.then ({ image, pixelRatio, multipart, takenAt }) ->
|
|
return null if not image
|
|
|
|
if image and data.userClip
|
|
crop(image, data.userClip, pixelRatio)
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
|
|
save: (data, details, screenshotsFolder) ->
|
|
type = getType(details)
|
|
|
|
name = data.name ? data.titles.join(RUNNABLE_SEPARATOR)
|
|
name = name.replace(invalidCharsRe, "")
|
|
name = [name, mime.extension(type)].join(".")
|
|
|
|
pathToScreenshot = path.join(screenshotsFolder, name)
|
|
|
|
debug("save", pathToScreenshot)
|
|
|
|
getBuffer(details)
|
|
.then (buffer) ->
|
|
fs.outputFileAsync(pathToScreenshot, buffer)
|
|
.then ->
|
|
fs.statAsync(pathToScreenshot).get("size")
|
|
.then (size) ->
|
|
dimensions = getDimensions(details)
|
|
|
|
{ multipart, pixelRatio, takenAt } = details
|
|
|
|
{
|
|
takenAt
|
|
dimensions
|
|
multipart
|
|
pixelRatio
|
|
size: bytes(size, {unitSeparator: " "})
|
|
path: pathToScreenshot
|
|
}
|
|
|
|
}
|