diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 60efc3ade7..3bf921eac9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -11,6 +11,7 @@ _Released 11/18/2025 (PENDING)_ - Fixed an issue where [`cy.wrap()`](https://docs.cypress.io/api/commands/wrap) would cause infinite recursion and freeze the Cypress App when called with objects containing circular references. Fixes [#24715](https://github.com/cypress-io/cypress/issues/24715). Addressed in [#32917](https://github.com/cypress-io/cypress/pull/32917). - Fixed an issue where top changes on test retries could cause attempt numbers to show up more than one time in the reporter and cause attempts to be lost in Test Replay. Addressed in [#32888](https://github.com/cypress-io/cypress/pull/32888). +- Fixed a regression where screenshot helper pixels were read outside the bitmap bounds, which also caused the associated unit test to throw `this.getPixelColor.restore is not a function`. Addresses [#32927](https://github.com/cypress-io/cypress/issues/32927). - Fixed an issue where stack traces that are used to determine a test's invocation details are sometimes incorrect. Addressed in [#32699](https://github.com/cypress-io/cypress/pull/32699) **Misc:** diff --git a/packages/server/lib/screenshots.ts b/packages/server/lib/screenshots.ts index ff624162eb..325e4f8ad6 100644 --- a/packages/server/lib/screenshots.ts +++ b/packages/server/lib/screenshots.ts @@ -103,12 +103,22 @@ const intToRGBA = function (int: number): RGBAWithName { // 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)) + const maxX = image.bitmap.width - 1 + const maxY = image.bitmap.height - 1 + + const getClampedPixel = (x: number, y: number) => { + const clampedX = Math.max(0, Math.min(Math.round(x), maxX)) + const clampedY = Math.max(0, Math.min(Math.round(y), maxY)) + + return intToRGBA(image.getPixelColor(clampedX, clampedY)) + } + + const topLeft = getClampedPixel(0, 0) + const topLeftRight = getClampedPixel(1 * pixelRatio, 0) + const topLeftDown = getClampedPixel(0, 1 * pixelRatio) + const bottomLeft = getClampedPixel(0, maxY) + const topRight = getClampedPixel(maxX, 0) + const bottomRight = getClampedPixel(maxX, maxY) topLeft.isNotWhite = !isWhite(topLeft) topLeftRight.isWhite = isWhite(topLeftRight) diff --git a/packages/server/test/unit/screenshots_spec.js b/packages/server/test/unit/screenshots_spec.js index d6c74b2556..7ba5b84f46 100644 --- a/packages/server/test/unit/screenshots_spec.js +++ b/packages/server/test/unit/screenshots_spec.js @@ -16,15 +16,31 @@ const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+ const iso8601Regex = /^\d{4}\-\d{2}\-\d{2}T\d{2}\:\d{2}\:\d{2}\.?\d*Z?$/ let ctx +let originalGetBrowsers describe('lib/screenshots', () => { before(async function () { + this.timeout(60000) + const { setCtx, makeDataContext, clearCtx } = require('../../lib/makeDataContext') // Clear and set up DataContext await clearCtx() setCtx(makeDataContext({})) ctx = require('../../lib/makeDataContext').getCtx() + // Provide a deterministic browser list so BrowserDataSource can resolve in CI environments + const availableBrowsers = [{ + name: 'chrome', + family: 'chromium', + channel: 'stable', + displayName: 'Chrome', + path: '/path/to/chrome', + version: '123.0.0', + majorVersion: '123', + }] + + originalGetBrowsers = ctx._apis.browserApi.getBrowsers + ctx._apis.browserApi.getBrowsers = async () => availableBrowsers Fixtures.scaffold() this.todosPath = Fixtures.projectPath('todos') @@ -69,26 +85,34 @@ describe('lib/screenshots', () => { clone: () => { return this.jimpImage }, + getPixelColor () { + throw new Error('getPixelColor should be stubbed') + }, } Jimp.prototype.composite = sinon.stub() // Jimp.prototype.getBuffer = sinon.stub().resolves(@buffer) }) - after(() => { + after(function () { + if (originalGetBrowsers) { + ctx._apis.browserApi.getBrowsers = originalGetBrowsers + } + return Fixtures.remove() }) context('.capture', () => { beforeEach(function () { - this.getPixelColor = sinon.stub() + this.getPixelColor = sinon.stub(this.jimpImage, 'getPixelColor') + const lastPixelIndex = this.jimpImage.bitmap.width - 1 + this.getPixelColor.withArgs(0, 0).returns('grey') this.getPixelColor.withArgs(1, 0).returns('white') this.getPixelColor.withArgs(0, 1).returns('white') - this.getPixelColor.withArgs(40, 0).returns('white') - this.getPixelColor.withArgs(0, 40).returns('white') - this.getPixelColor.withArgs(40, 40).returns('black') - this.jimpImage.getPixelColor = this.getPixelColor + this.getPixelColor.withArgs(lastPixelIndex, 0).returns('white') + this.getPixelColor.withArgs(0, lastPixelIndex).returns('white') + this.getPixelColor.withArgs(lastPixelIndex, lastPixelIndex).returns('black') sinon.stub(Jimp, 'read').resolves(this.jimpImage) const intToRGBA = sinon.stub(Jimp, 'intToRGBA') @@ -157,6 +181,32 @@ describe('lib/screenshots', () => { }) }) + it('does not read helper pixels outside the bitmap bounds', function () { + this.getPixelColor.restore() + + const maxX = this.jimpImage.bitmap.width - 1 + const maxY = this.jimpImage.bitmap.height - 1 + const pixelColors = new Map([ + ['0,0', 'white'], + ['1,0', 'white'], + ['0,1', 'white'], + [`${maxX},0`, 'white'], + [`0,${maxY}`, 'white'], + [`${maxX},${maxY}`, 'black'], + ]) + + this.getPixelColor = sinon.stub().callsFake((x, y) => { + expect(x).to.be.within(0, maxX) + expect(y).to.be.within(0, maxY) + + return pixelColors.get(`${x},${y}`) || 'white' + }) + + this.jimpImage.getPixelColor = this.getPixelColor + + return screenshots.capture(this.appData, this.automate) + }) + describe('runner hidden', () => { beforeEach(function () { this.currentTest.timeout(5000)