Files
cypress/packages/server/lib/screenshots.ts
T
Yashodhan e8a7c3f5f8 fix: prevent overwriting of video files (#30673)
* fix: use getPath to prevent video file overwrite

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* chore: update changelog

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* test: add system e2e for the retain videos fix

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* Update cli/CHANGELOG.md

Co-authored-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com>

* Update types for screenshots to be in util/fs

* Fix changelog entry placement

* fix extension type

* more types fixes

* fix: add required field in getPath call to satisfy ts

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: sync Data interface from develop branch

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: update SavedDetails type to better definition

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* update changelog

* break out type import into unique line to allow mksnapshot to work

* fix: minor comment fixes

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: change videoPath fn signature as per comment

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: convert the test to async/await

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* Update CHANGELOG.md

---------

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>
Co-authored-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com>
Co-authored-by: AtofStryker <bglesias@gmail.com>
2025-04-29 10:01:48 -04:00

468 lines
12 KiB
TypeScript

import _ from 'lodash'
import Debug from 'debug'
import mime from 'mime'
import Promise from 'bluebird'
import dataUriToBuffer from 'data-uri-to-buffer'
import Jimp from 'jimp'
import sizeOf from 'image-size'
import colorString from 'color-string'
import * as plugins from './plugins'
import { fs, getPath } from './util/fs'
import type { Data, ScreenshotsFolder } from './util/fs'
let debug = Debug('cypress:server:screenshot')
// internal id incrementor
let __ID__: string | null = null
// TODO: This is likely not representative of the entire Type and should be updated
interface Details {
image: any
pixelRatio: any
multipart: any
takenAt: Date
}
// TODO: This is likely not representative of the entire Type and should be updated
interface SavedDetails {
size?: string
takenAt?: Date
dimensions?: {
width: number
height: number
}
multipart?: any
pixelRatio?: number
name?: any
specName?: string
testFailure?: boolean
path?: string
}
// 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)
}) as Debug.Debugger
interface RGBA {
r: number
g: number
b: number
a: number
}
const isBlack = (rgba: RGBA): boolean => {
return `${rgba.r}${rgba.g}${rgba.b}` === '000'
}
const isWhite = (rgba: RGBA): boolean => {
return `${rgba.r}${rgba.g}${rgba.b}` === '255255255'
}
interface RGBAWithName extends RGBA {
name?: string
isNotWhite?: boolean
isWhite?: boolean
isBlack?: boolean
}
const intToRGBA = function (int: number): RGBAWithName {
const obj: RGBAWithName = Jimp.intToRGBA(int) as RGBAWithName
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' or `hideRunnerUi=true` 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: Data, automate, conditionFn) {
let attempt
const start = new Date()
let tries = 0
return (attempt = function () {
tries++
const totalDuration = new Date().getTime() - start.getTime()
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 isMultipart = (data: 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: Data, image) {
const pixelRatio = image.bitmap.width / data.viewport.width
const hasPixels = hasHelperPixels(image, pixelRatio)
const app = data.appOnly
const subject = app ? 'app' : 'runner'
// if we are app or the runner is already hidden, we dont need helper pixels else we do!
const passes = (app || data.hideRunnerUi) ? !hasPixels : hasPixels
debug('pixelConditionFn %o', {
pixelRatio,
subject,
hasPixels,
expectedPixels: !app,
})
return passes
}
let multipartImages: { data: Data, image, takenAt, __ID__ }[] = []
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: 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: 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: string[] = []
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)
// @ts-expect-error
.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 getPathToScreenshot = function (data: Data, details: Details, screenshotsFolder: ScreenshotsFolder) {
const ext = mime.getExtension(getType(details))
return getPath(data, ext, screenshotsFolder, data.overwrite)
}
export = {
crop,
getPath,
clearMultipartState,
imagesMatch,
capture (data: 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 && 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 (data.appOnly || isMultipart(data) || data.hideRunnerUi) {
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: Data, details: Details, screenshotsFolder: ScreenshotsFolder) {
return getPathToScreenshot(data, details, screenshotsFolder)
.then((pathToScreenshot) => {
debug('save', pathToScreenshot)
return getBuffer(details)
.then((buffer) => {
return fs.outputFile(pathToScreenshot, buffer)
}).then(() => {
// @ts-expect-error TODO: size is not assignable here
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: Data, details: SavedDetails) {
const duration = new Date().getTime() - new Date(data.startTime).getTime()
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'))
})
},
}