Files
cypress/packages/server/lib/screenshots.coffee
T
Zach Bloomquist a3265ccdef Control Chrome cookies through CDP (#5297)
* 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 commit a1b86d9413.

* 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 commit 22c5e78670.

* 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>
2019-10-21 17:07:53 -04:00

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"))
}