Skip to content

Commit

Permalink
fix: screenshot() times out when the main Cypress tab is not focused (#…
Browse files Browse the repository at this point in the history
…29038)

* activate main cypress tab before taking a screenshot

* new tests to cover page activation behavior

* updates changelog

* whitespace

* fix check-ts

* reduce extension failure timeout to 500ms to account for origin bridge timeout

* only use tab activation workaround in chrome; default to Page.bringToFront in headless mode

* update unit tests

* swap order of tests in 5016 system test

* some debugging to try and hunt down firefox issue

* rm debug prev added - looks like sys test passed that time?

* rm debug emit from v2 extension
  • Loading branch information
cacieprins committed Mar 8, 2024
1 parent 245fd97 commit e785318
Show file tree
Hide file tree
Showing 11 changed files with 252 additions and 17 deletions.
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Expand Up @@ -9,6 +9,7 @@ _Released 2/27/2024 (PENDING)_

**Bugfixes:**

- Changed screenshot capture behavior in Chromium to activate the main Cypress tab before capturing. This prevents screenshot capture from timing out in certain situations. Fixed in [#29038](https://github.com/cypress-io/cypress/pull/29038). Fixes [#5016](https://github.com/cypress-io/cypress/issues/5016)
- Fixed an issue where `.click()` commands on children of disabled elements would still produce "click" events -- even without `{ force: true }`. Fixes [#28788](https://github.com/cypress-io/cypress/issues/28788).
- Changed RequestBody type to allow for boolean and null literals to be passed as body values. [#28789](https://github.com/cypress-io/cypress/issues/28789)

Expand Down
1 change: 0 additions & 1 deletion packages/extension/app/v2/background.js
Expand Up @@ -337,7 +337,6 @@ const automation = {
})
.then(fn)
},

}

module.exports = automation
70 changes: 64 additions & 6 deletions packages/server/lib/browsers/cdp_automation.ts
Expand Up @@ -165,10 +165,10 @@ export class CdpAutomation implements CDPClient {
on: OnFn
off: OffFn
send: SendDebuggerCommand
private frameTree: any
private gettingFrameTree: any
private frameTree: Protocol.Page.FrameTree | undefined
private gettingFrameTree: Promise<void> | undefined | null

private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private offFn: OffFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation) {
private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private offFn: OffFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation, private focusTabOnScreenshot: boolean = false, private isHeadless: boolean = false) {
onFn('Network.requestWillBeSent', this.onNetworkRequestWillBeSent)
onFn('Network.responseReceived', this.onResponseReceived)
onFn('Network.requestServedFromCache', this.onRequestServedFromCache)
Expand Down Expand Up @@ -197,14 +197,62 @@ export class CdpAutomation implements CDPClient {
await this.sendDebuggerCommandFn('Page.startScreencast', screencastOpts)
}

static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, offFn: OffFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, protocolManager?: ProtocolManagerShape): Promise<CdpAutomation> {
const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, offFn, sendCloseCommandFn, automation)
static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, offFn: OffFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, protocolManager?: ProtocolManagerShape, focusTabOnScreenshot: boolean = false, isHeadless?: boolean): Promise<CdpAutomation> {
const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, offFn, sendCloseCommandFn, automation, focusTabOnScreenshot, isHeadless)

await sendDebuggerCommandFn('Network.enable', protocolManager?.networkEnableOptions ?? DEFAULT_NETWORK_ENABLE_OPTIONS)

return cdpAutomation
}

private async activateMainTab () {
const ActivationTimeoutMessage = 'Unable to communicate with Cypress Extension'

const sendActivationMessage = `
(() => {
if (document.defaultView !== top) { return Promise.resolve() }
return new Promise((res) => {
const onMessage = (ev) => {
if (ev.data.message === 'cypress:extension:main:tab:activated') {
window.removeEventListener('message', onMessage)
res()
}
}
window.addEventListener('message', onMessage)
window.postMessage({ message: 'cypress:extension:activate:main:tab' })
})
})()`

if (this.isHeadless) {
debugVerbose('Headless, so bringing page to front instead of negotiating with extension')
await this.sendDebuggerCommandFn('Page.bringToFront')
} else {
try {
debugVerbose('sending activation message ', sendActivationMessage)
await Promise.race([
this.sendDebuggerCommandFn('Runtime.evaluate', {
expression: sendActivationMessage,
awaitPromise: true,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error(ActivationTimeoutMessage)), 500)
}),
])
} catch (e) {
debugVerbose('Error occurred while attempting to activate main tab: ', e)
// If rejected due to timeout, fall back to bringing the main tab to focus -
// this will steal window focus, so it is a last resort. If any other error
// was thrown, re-throw as it was unexpected.
if ((e as Error).message === ActivationTimeoutMessage) {
await this.sendDebuggerCommandFn('Page.bringToFront')
} else {
throw e
}
}
}
}

private onNetworkRequestWillBeSent = async (params: Protocol.Network.RequestWillBeSentEvent) => {
debugVerbose('received networkRequestWillBeSent %o', params)

Expand Down Expand Up @@ -420,7 +468,7 @@ export class CdpAutomation implements CDPClient {
client.on('Page.frameDetached', this._updateFrameTree(client, 'Page.frameDetached'))
}

onRequest = (message, data) => {
onRequest = async (message, data) => {
let setCookie

switch (message) {
Expand Down Expand Up @@ -494,6 +542,16 @@ export class CdpAutomation implements CDPClient {
case 'remote:debugger:protocol':
return this.sendDebuggerCommandFn(data.command, data.params, data.sessionId)
case 'take:screenshot':
debugVerbose('capturing screenshot')

if (this.focusTabOnScreenshot) {
try {
await this.activateMainTab()
} catch (e) {
debugVerbose('Error while attempting to activate main tab: %O', e)
}
}

return this.sendDebuggerCommandFn('Page.captureScreenshot', { format: 'png' })
.catch((err) => {
throw new Error(`The browser responded with an error when Cypress attempted to take a screenshot.\n\nDetails:\n${err.message}`)
Expand Down
2 changes: 1 addition & 1 deletion packages/server/lib/browsers/chrome.ts
Expand Up @@ -325,7 +325,7 @@ const _handleDownloads = async function (client, downloadsFolder: string, automa
let onReconnect: (client: CriClient) => Promise<void> = async () => undefined

const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise<void>, options: BrowserLaunchOpts) => {
const cdpAutomation = await CdpAutomation.create(client.send, client.on, client.off, resetBrowserTargets, automation, options.protocolManager)
const cdpAutomation = await CdpAutomation.create(client.send, client.on, client.off, resetBrowserTargets, automation, options.protocolManager, true, options.isTextTerminal)

automation.use(cdpAutomation)

Expand Down
76 changes: 67 additions & 9 deletions packages/server/test/unit/browsers/cdp_automation_spec.ts
Expand Up @@ -465,20 +465,78 @@ context('lib/browsers/cdp_automation', () => {
})

describe('take:screenshot', () => {
it('resolves with base64 data URL', function () {
beforeEach(function () {
this.sendDebuggerCommand.withArgs('Browser.getVersion').resolves({ protocolVersion: '1.3' })
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').resolves({ data: 'foo' })
})

describe('when tab focus behavior default (disabled)', function () {
it('resolves with base64 data URL', function () {
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').resolves({ data: 'foo' })

return expect(this.onRequest('take:screenshot'))
.to.eventually.equal('data:image/png;base64,foo')
})

it('rejects nicely if Page.captureScreenshot fails', function () {
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').rejects()

return expect(this.onRequest('take:screenshot'))
.to.eventually.equal('data:image/png;base64,foo')
return expect(this.onRequest('take:screenshot'))
.to.be.rejectedWith('The browser responded with an error when Cypress attempted to take a screenshot.')
})
})

it('rejects nicely if Page.captureScreenshot fails', function () {
this.sendDebuggerCommand.withArgs('Browser.getVersion').resolves({ protocolVersion: '1.3' })
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').rejects()
describe('when tab focus behavior is enabled', function () {
let requireTabFocus
let isHeadless

return expect(this.onRequest('take:screenshot'))
.to.be.rejectedWith('The browser responded with an error when Cypress attempted to take a screenshot.')
beforeEach(() => {
requireTabFocus = true
})

describe('when headless', () => {
beforeEach(() => {
isHeadless = true
})

it('does not try to comm with extension, simply brings page to front', async function () {
cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.offFn, this.sendCloseTargetCommand, this.automation, undefined, requireTabFocus, isHeadless)
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').resolves({ data: 'foo' })

expect(cdpAutomation.onRequest('take:screenshot', undefined)).to.eventually.equal('data:image/png;base64,foo')
expect(this.sendDebuggerCommand).not.to.be.calledWith('Runtime.evaluate')
expect(this.sendDebuggerCommand).to.be.calledWith('Page.bringToFront')
})
})

describe('when not headless', () => {
beforeEach(async function () {
isHeadless = false
cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.offFn, this.sendCloseTargetCommand, this.automation, undefined, requireTabFocus, isHeadless)
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').resolves({ data: 'foo' })
})

describe('and the extension activates the tab', function () {
beforeEach(function () {
this.sendDebuggerCommand.withArgs('Runtime.evaluate').resolves()
this.sendDebuggerCommand.withArgs('Page.captureScreenshot').resolves({ data: 'foo' })
})

it('captures the screenshot', function () {
expect(cdpAutomation.onRequest('take:screenshot', undefined)).to.eventually.equal('data:image/png;base64,foo')
})
})

describe('and the extension fails to activate the tab', function () {
beforeEach(function () {
this.sendDebuggerCommand.withArgs('Runtime.evaluate').rejects(new Error('Unable to communicate with Cypress Extension'))
this.sendDebuggerCommand.withArgs('Page.bringToFront').resolves()
})

it('captures the screenshot', function () {
expect(cdpAutomation.onRequest('take:screenshot', undefined)).to.eventually.equal('data:image/png;base64,foo')
})
})
})
})
})

Expand Down
74 changes: 74 additions & 0 deletions system-tests/__snapshots__/issue_5016_spec.js
@@ -0,0 +1,74 @@
exports['e2e issue 5016 - screenshot times out after clicking target _blank / fails but does not timeout taking screenshot'] = `
====================================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 1 found (issue-5016.cy.js) │
│ Searched: cypress/e2e/**/*.cy.{js,jsx,ts,tsx} │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: issue-5016.cy.js (1 of 1)
issue 5016
✓ should take a normal screenshot
1) should fail but not timeout while taking the screenshot
✓ should not timeout taking screenshot when not failing
2 passing
1 failing
1) issue 5016
should fail but not timeout while taking the screenshot:
AssertionError: Timed out retrying after 4050ms: expected '<a>' to have attribute 'foo'
[stack trace lines]
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 3 │
│ Passing: 2 │
│ Failing: 1 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 3 │
│ Video: false │
│ Duration: X seconds │
│ Spec Ran: issue-5016.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
(Screenshots)
- /XXX/XXX/XXX/cypress/screenshots/issue-5016.cy.js/issue 5016 -- should take a no (YxX)
rmal screenshot.png
- /XXX/XXX/XXX/cypress/screenshots/issue-5016.cy.js/issue 5016 -- should fail but (YxX)
not timeout while taking the screenshot (failed).png
- /XXX/XXX/XXX/cypress/screenshots/issue-5016.cy.js/issue 5016 -- should not timeo (YxX)
ut taking screenshot when not failing.png
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✖ issue-5016.cy.js XX:XX 3 2 1 - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✖ 1 of 1 failed (100%) XX:XX 3 2 1 - -
`
@@ -0,0 +1,6 @@
module.exports = {
screenshotOnRunFailure: true,
e2e: {
supportFile: false,
},
}
@@ -0,0 +1,18 @@
// https://github.com/cypress-io/cypress/issues/5016
describe('issue 5016', {
screenshotOnRunFailure: true,
}, function () {
it('should take a normal screenshot', function () {
cy.visit('/cypress/fixtures/issue-5016/index.html').screenshot()
})

it('should fail but not timeout while taking the screenshot', function () {
cy.visit('/cypress/fixtures/issue-5016/index.html')
cy.get('a').click().should('have.attr', 'foo')
})

it('should not timeout taking screenshot when not failing', function () {
cy.visit('/cypress/fixtures/issue-5016/index.html')
cy.get('a').click().screenshot()
})
})
@@ -0,0 +1,5 @@
<!doctype html>

<body>
<a target="_blank" href="./new.html">New Tab</a>
</body>
@@ -0,0 +1,3 @@
<!doctype html>

<html></html>
13 changes: 13 additions & 0 deletions system-tests/test/issue_5016_spec.js
@@ -0,0 +1,13 @@
const systemTests = require('../lib/system-tests').default

describe('e2e issue 5016 - screenshot times out after clicking target _blank', function () {
systemTests.setup()

systemTests.it('fails but does not timeout taking screenshot', {
project: 'config-screenshot-on-failure-enabled',
sanitizeScreenshotDimensions: true,
snapshot: true,
expectedExitCode: 1,
browser: '!webkit',
})
})

3 comments on commit e785318

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on e785318 Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.7/linux-arm64/develop-e78531820a0a38b9eee9290a490848c6e4e266ea/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on e785318 Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.7/linux-x64/develop-e78531820a0a38b9eee9290a490848c6e4e266ea/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on e785318 Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.7/darwin-x64/develop-e78531820a0a38b9eee9290a490848c6e4e266ea/cypress.tgz

Please sign in to comment.