mirror of
https://github.com/cypress-io/cypress.git
synced 2026-04-22 23:20:24 -05:00
a3265ccdef
* try connecting to chrome remote interface * linting * print CRI targets for better debugging * linting * load empty tab first when connecting to CRI * first load blank page, then navigate * Page.navigate is working * linting * remove title * add mocha banner * more banners * update some server unit tests * update integration test * document how to run single driver spec file * set the focus back on the page before navigating from blank chrome tab * update server unit test * do not store Chrome remote interface reference for now * record video of the Chrome tab using screencast API * use dynamic port to connect to Chrome remote interface * update unit tests * refactoring * wrap chrome remote interface in our interface, limit access to send * resolved merge * fix reference * passing run unit spec * stub canary search for CI to pass * add build step to packages/server * update chrome spec * do not build js on install for server * updated spec snapshots * update 6_visit_spec snapshot * update snapshot for 6_web_security_spec * update snapshot for 3_plugins_spec * update snapshot for 3_user_agent_spec * update snapshot for 5_stdout_spec * update snapshot for 2_browser_path_spec * do not git ignore js files, add note why * update several snapshots with video on Chrome * update visit performance snapshot * add chrome-remote-interface dependency * cleanup coffeescript conversion to JS, fix some type errors, make parallel override clearer * fix failing tests * Fix snapshot - now we do record in Chrome, so warning message is no longer there. * remove chrome warnings about not recording from snapshot * Remove performance tests from 6_visit_spec snapshot * Remove error from snapshot * Add newline back to cy_visit_performance_spec snapshot * Use CDP to control Chrome cookies + screenshot * Add devtools types * Cleanup * Cleanup * Add guards for minimum CDP version * Fix failing tests * Split cdp_automation_spec out of electron_spec * Move takeScreenshot to cri-client * Navigate to about:blank * look for blank page url * add note about avoiding Prettier * disable prettier a little more * call chrome remote interface close after each spec * return promise when starting screencast * update failing unit tests, add cri client close test * update integration test * Add verbose debug statements to cri-client * Use connect.createRetryingSocket for CDP connection * record video from chrome browsers * add method for validating browser family * update e2e spec snapshot * update 4_request_spec snapshot * update snapshot for spec 1_commands_outside_of_test_spec * update snapshot for 3_plugins_spec * update snapshot for spec 3_user_agent_spec * try: Always log video capturing errors * update snapshot for 2_browser_path_spec * update snapshot for 2_cookies_spec * better browser family test * update snapshot for 5_stdout_spec * update snapshot for 5_subdomain_spec * Add protocol_spec tests * do not capture video during performance test * Add test for VIDEO_POST_PROCESSING_FAILED warning * Add basic cookie validation in cy.setCookie * Update cdp_automation to throw on Network.setCookie failure code * Update tests 🎉 * Update snapshot * Fix test * Remove redundant logs, cleanup * Add cri-client_spec, fix some small bugs, improve errors * Update dep * use client.on to register screencast callback * use isCookieName * strict-cookie-parser@3.1.0 * cleanup prettier, extract some functions, switch to browser.family * moar cleanup and fixes * add logging to the cri-client so we can see every message sent + received to the cdp protocol * bump bluebird to 3.7.0 for .tapCatch addition * Fix unit tests * WIP: update e2e test to ensure that duration of the video matches what we expect * Test duration of recorded video * Run 6_video_compression in chrome + electron * Cleanup * finish ffmpeg duration verification * Update 8_reporters_spec snapshot * Fix cri-client test * Update CRI close logic to monkey-patch browser.kill * add isBrowserFamily back * make it possible for remote-debugging-port to get overridden * Make CDP timeout 5s; add unit, e2e tests for CDP failure; add user-friendly CDP failure error * Update tests * Use CYPRESS_REMOTE_DEBUGGING_PORT to set CDP port; update CDP error message * Change new Buffer to Buffer.from * Apply name validation on all cookie commands * Just throw on Chrome start if the CDP version is < 1.3 * Fix cypress_spec * Use CDP to set resolution + scale factor in Chrome e2e * Revert "Use CDP to set resolution + scale factor in Chrome e2e" This reverts commita1b86d9413. * use CYPRESS_FORCE_BROWSER_SCALE to force standard resolution * don't do --window-size --kiosk * Use CDP to set resolution + scale factor in Chrome e2e * Revert "use CYPRESS_FORCE_BROWSER_SCALE to force standard resolution" This reverts commit22c5e78670. * Use Page.captureScreenshot for Electron + Chrome, reduce logic * Use before() task to force device metrics in Chrome * Fix protocol_spec * Update 7_record_spec to allow for before() hook * Update 6_task_spec snapshot * Appease eslint * Update hooks in 5_spec_isolation snapshot * some general promisification and cleanup * feedback on pluginsfile * cdp_automation feedback * chrome.coff feedback * feedback * run e2e tests on port 4466, ensure no e2e test ever runs on 5566 to prevent conflicting with debugger port * accept new 'remote:debugger:protocol' automation command to control device metrics overrides * update web security e2e to run on electron + chrome * run web security tests in electorn, disable context isolation * pass disable-site-isolation-trials to Electron so webSecurity works * Fix errors in e2e tests caused by extra log item * fix cri-client unit tests * fancy arrows in log message Co-authored-by: Gleb Bahmutov <gleb.bahmutov@gmail.com> Co-authored-by: Brian Mann <brian.mann86@gmail.com> Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
475 lines
13 KiB
CoffeeScript
475 lines
13 KiB
CoffeeScript
_ = require("lodash")
|
|
mime = require("mime")
|
|
path = require("path")
|
|
Promise = require("bluebird")
|
|
dataUriToBuffer = require("data-uri-to-buffer")
|
|
Jimp = require("jimp")
|
|
sizeOf = require("image-size")
|
|
colorString = require("color-string")
|
|
sanitize = require("sanitize-filename")
|
|
debug = require("debug")("cypress:server:screenshot")
|
|
plugins = require("./plugins")
|
|
fs = require("./util/fs")
|
|
glob = require("./util/glob")
|
|
pathHelpers = require("./util/path_helpers")
|
|
|
|
RUNNABLE_SEPARATOR = " -- "
|
|
pathSeparatorRe = /[\\\/]/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
|
|
|
|
## 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 \n %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", dataUrl.slice(0, 100))
|
|
|
|
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 #{x}, #{y}")
|
|
debug(" to #{width} x #{height}")
|
|
|
|
image.clone().crop(x, y, width, height)
|
|
|
|
pixelConditionFn = (data, image) ->
|
|
pixelRatio = image.bitmap.width / data.viewport.width
|
|
|
|
hasPixels = hasHelperPixels(image, pixelRatio)
|
|
app = isAppOnly(data)
|
|
|
|
subject = if app then "app" else "runner"
|
|
|
|
## if we are app, we dont need helper pixels else we do!
|
|
passes = if app then not hasPixels else hasPixels
|
|
|
|
debug("pixelConditionFn %o", {
|
|
pixelRatio,
|
|
subject,
|
|
hasPixels,
|
|
expectedPixels: !app
|
|
})
|
|
|
|
return passes
|
|
|
|
multipartImages = []
|
|
|
|
# compareUntilPixelsDiffer = (img1, img2) ->
|
|
## NOTE: this is for comparing pixel by pixel which is useful
|
|
## if you're trying to dig into the specific pixel differences
|
|
##
|
|
## we're making this as efficient as possible because
|
|
## there are significant performance problems
|
|
## getting a hash or buffer of all the image data.
|
|
##
|
|
## instead we will walk through two images comparing
|
|
## them pixel by pixel until they don't match.
|
|
#
|
|
# iterations = 0
|
|
#
|
|
# { width, height } = img2.bitmap
|
|
#
|
|
# data1 = img1.bitmap.data
|
|
# data2 = img2.bitmap.data
|
|
#
|
|
# ret = (differences) ->
|
|
# return {
|
|
# iterations
|
|
# differences
|
|
# }
|
|
#
|
|
# for y in [0...height]
|
|
# for x in [0...width]
|
|
# iterations += 1
|
|
#
|
|
# idx = (width * y + x) << 2
|
|
#
|
|
# pix1 = data1.readUInt32BE(idx)
|
|
# pix2 = data2.readUInt32BE(idx)
|
|
#
|
|
# if pix1 isnt pix2
|
|
# return ret([
|
|
# intToRGBA(pix1),
|
|
# intToRGBA(pix2)
|
|
# ])
|
|
#
|
|
# return ret(null)
|
|
|
|
clearMultipartState = ->
|
|
debug("clearing %d cached multipart images", multipartImages.length)
|
|
multipartImages = []
|
|
|
|
imagesMatch = (img1, img2) ->
|
|
## using Buffer::equals here
|
|
img1.bitmap.data.equals(img2.bitmap.data)
|
|
|
|
lastImagesAreDifferent = (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
|
|
|
|
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 not matches
|
|
|
|
multipartConditionFn = (data, image) ->
|
|
if data.current is 1
|
|
pixelConditionFn(data, image) and lastImagesAreDifferent(data, image)
|
|
else
|
|
lastImagesAreDifferent(data, image)
|
|
|
|
stitchScreenshots = (pixelRatio) ->
|
|
fullWidth = _
|
|
.chain(multipartImages)
|
|
.map("data.clip.width")
|
|
.min()
|
|
.multiply(pixelRatio)
|
|
.value()
|
|
|
|
fullHeight = _
|
|
.chain(multipartImages)
|
|
.sumBy("data.clip.height")
|
|
.multiply(pixelRatio)
|
|
.value()
|
|
|
|
debug("stitch #{multipartImages.length} images together")
|
|
|
|
takenAts = []
|
|
heightMarker = 0
|
|
fullImage = new Jimp(fullWidth, fullHeight)
|
|
|
|
_.each multipartImages, ({ data, image, takenAt }) ->
|
|
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 }
|
|
|
|
getType = (details) ->
|
|
if details.buffer
|
|
details.buffer.type
|
|
else
|
|
details.image.getMIME()
|
|
|
|
getBuffer = (details) ->
|
|
if details.buffer
|
|
Promise.resolve(details.buffer)
|
|
else
|
|
Promise
|
|
.promisify(details.image.getBuffer)
|
|
.call(details.image, Jimp.AUTO)
|
|
|
|
getDimensions = (details) ->
|
|
pick = (obj) ->
|
|
_.pick(obj, "width", "height")
|
|
|
|
if details.buffer
|
|
pick(sizeOf(details.buffer))
|
|
else
|
|
pick(details.image.bitmap)
|
|
|
|
ensureUniquePath = (withoutExt, extension, num = 0) ->
|
|
fullPath = if num then "#{withoutExt} (#{num}).#{extension}" else "#{withoutExt}.#{extension}"
|
|
fs.pathExists(fullPath)
|
|
.then (found) ->
|
|
if found
|
|
return ensureUniquePath(withoutExt, extension, num += 1)
|
|
return fullPath
|
|
|
|
sanitizeToString = (title) ->
|
|
## test titles may be values which aren't strings like
|
|
## null or undefined - so convert before trying to sanitize
|
|
sanitize(_.toString(title))
|
|
|
|
getPath = (data, ext, screenshotsFolder) ->
|
|
specNames = (data.specName or "")
|
|
.split(pathSeparatorRe)
|
|
|
|
if data.name
|
|
names = data.name.split(pathSeparatorRe).map(sanitize)
|
|
else
|
|
names = _
|
|
.chain(data.titles)
|
|
.map(sanitizeToString)
|
|
.join(RUNNABLE_SEPARATOR)
|
|
.concat([])
|
|
.value()
|
|
|
|
# truncate file names to be less than 220 characters
|
|
# to accomodate filename size limits
|
|
maxFileNameLength = 220
|
|
index = names.length - 1
|
|
|
|
if names[index].length > maxFileNameLength
|
|
names[index] = _.truncate(names[index], {
|
|
length: maxFileNameLength,
|
|
omission: ''
|
|
})
|
|
|
|
## append (failed) to the last name
|
|
if data.testFailure
|
|
names[index] = names[index] + " (failed)"
|
|
|
|
withoutExt = path.join(screenshotsFolder, specNames..., names...)
|
|
|
|
ensureUniquePath(withoutExt, ext)
|
|
|
|
getPathToScreenshot = (data, details, screenshotsFolder) ->
|
|
ext = mime.extension(getType(details))
|
|
|
|
getPath(data, ext, screenshotsFolder)
|
|
|
|
module.exports = {
|
|
crop
|
|
|
|
getPath
|
|
|
|
clearMultipartState
|
|
|
|
imagesMatch
|
|
|
|
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()
|
|
|
|
debug("storing image for future comparison", __ID__)
|
|
|
|
multipartImages.push({ data, image, takenAt, __ID__ })
|
|
|
|
if data.current is data.total
|
|
{ image } = stitchScreenshots(pixelRatio)
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
else
|
|
return {}
|
|
|
|
if isAppOnly(data) or isMultipart(data)
|
|
image = 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
|
|
image = crop(image, data.userClip, pixelRatio)
|
|
|
|
return { image, pixelRatio, multipart, takenAt }
|
|
|
|
save: (data, details, screenshotsFolder) ->
|
|
getPathToScreenshot(data, details, screenshotsFolder)
|
|
.then (pathToScreenshot) ->
|
|
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
|
|
|
|
{
|
|
size
|
|
takenAt
|
|
dimensions
|
|
multipart
|
|
pixelRatio
|
|
name: data.name
|
|
specName: data.specName
|
|
testFailure: data.testFailure
|
|
path: pathToScreenshot
|
|
}
|
|
|
|
afterScreenshot: (data, details) ->
|
|
duration = new Date() - new Date(data.startTime)
|
|
|
|
details = _.extend({}, data, details, { duration })
|
|
details = _.pick(details, "size", "takenAt", "dimensions", "multipart", "pixelRatio", "name", "specName", "testFailure", "path", "scaled", "blackout", "duration")
|
|
|
|
if not plugins.has("after:screenshot")
|
|
return Promise.resolve(details)
|
|
|
|
plugins.execute("after:screenshot", details)
|
|
.then (updates) =>
|
|
if not _.isPlainObject(updates)
|
|
return details
|
|
|
|
_.extend(details, _.pick(updates, "size", "dimensions", "path"))
|
|
|
|
}
|