fix: handle download from element missing download attribute (#28222)

This commit is contained in:
Emily Rohrbough
2023-11-06 14:06:59 -06:00
committed by GitHub
parent db8609e54f
commit eab1730dbb
15 changed files with 188 additions and 18 deletions

View File

@@ -5,6 +5,8 @@ _Released 11/7/2023 (PENDING)_
**Bugfixes:**
- Fixed an issue where clicking a link to download a file could cause a page load timeout when the download attribute was missing. Note: download behaviors in experimental Webkit are still an issue. Fixes [#14857](https://github.com/cypress-io/cypress/issues/14857).
- Fixed an issue to account for canceled and failed downloads to correctly reflect these status in Command log as a download failure where previously it would be pending. Fixed in [#28222](https://github.com/cypress-io/cypress/pull/28222).
- Fixed an issue determining visibility when an element is hidden by an ancestor with a shared edge. Fixes [#27514](https://github.com/cypress-io/cypress/issues/27514).
- We now pass a flag to Chromium browsers to disable Chrome translation, both the manual option and the popup prompt, when a page with a differing language is detected. Fixes [#28225](https://github.com/cypress-io/cypress/issues/28225).
- Fixed an issue where in chromium based browsers, global style updates can trigger flooding of font face requests in DevTools and Test Replay. This can affect performance due to the flooding of messages in CDP. Fixes [#28150](https://github.com/cypress-io/cypress/issues/28150) and [#28215](https://github.com/cypress-io/cypress/issues/28215).

View File

@@ -148,6 +148,9 @@ export class EventManager {
case 'complete:download':
Cypress.downloads.end(data)
break
case 'canceled:download':
Cypress.downloads.end(data, true)
break
default:
break
}

View File

@@ -4,6 +4,7 @@ describe('src/cypress/downloads', () => {
let log
let snapshot
let end
let error
let downloads
let downloadItem = {
id: '1',
@@ -11,13 +12,16 @@ describe('src/cypress/downloads', () => {
url: 'http://localhost:1234/location.csv',
mime: 'text/csv',
}
let action
beforeEach(() => {
end = cy.stub()
snapshot = cy.stub().returns({ end })
error = cy.stub()
snapshot = cy.stub().returns({ end, error })
log = cy.stub().returns({ snapshot })
action = cy.stub()
downloads = $Downloads.create({ log })
downloads = $Downloads.create({ action, log })
})
context('#start', () => {
@@ -51,9 +55,21 @@ describe('src/cypress/downloads', () => {
downloads.start(downloadItem)
downloads.end({ id: '1' })
expect(action).to.be.calledWith('app:download:received')
expect(snapshot).to.be.called
expect(end).to.be.called
})
it('fails with snapshot if matching log exists', () => {
downloads.start(downloadItem)
downloads.end({ id: '1' }, true)
expect(action).to.be.calledWith('app:download:received')
expect(snapshot).to.be.called
expect(end).not.to.be.called
expect(error).to.be.called
})
it('is a noop if matching log does not exist', () => {
downloads.end({ id: '1' })
@@ -62,3 +78,35 @@ describe('src/cypress/downloads', () => {
})
})
})
describe('download behavior', () => {
beforeEach(() => {
cy.visit('/fixtures/downloads.html')
})
it('downloads from anchor tag with download attribute', () => {
cy.exec(`rm -f ${Cypress.config('downloadsFolder')}/downloads_records.csv`)
cy.readFile(`${Cypress.config('downloadsFolder')}/downloads_records.csv`).should('not.exist')
// trigger download
cy.get('[data-cy=download-csv]').click()
cy.readFile(`${Cypress.config('downloadsFolder')}/downloads_records.csv`)
.should('contain', '"Joe","Smith"')
})
it('downloads from anchor tag without download attribute', { browser: '!webkit' }, () => {
cy.exec(`rm -f ${Cypress.config('downloadsFolder')}/downloads_records.csv`)
cy.readFile(`${Cypress.config('downloadsFolder')}/downloads_records.csv`).should('not.exist')
// trigger download
cy.get('[data-cy=download-without-download-attr]').click()
cy.readFile(`${Cypress.config('downloadsFolder')}/downloads_records.csv`)
.should('contain', '"Joe","Smith"')
})
it('invalid download path from anchor tag with download attribute', () => {
// attempt to download
cy.get('[data-cy=invalid-download]').click()
cy.readFile(`${Cypress.config('downloadsFolder')}/downloads_does_not_exist.csv`).should('not.exist')
})
})

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h3>Download CSV</h3>
<a data-cy="download-csv" href="downloads_records.csv" download>downloads_records.csv</a>
<h3>Download CSV</h3>
<a data-cy="download-without-download-attr" href="downloads_records.csv">download csv from anchor tag without download attr</a>
<h3>Download CSV</h3>
<a data-cy="invalid-download" href="downloads_does_not_exist.csv" download>broken download link</a>
</body>
</html>

View File

@@ -0,0 +1,2 @@
"First name","Last name","Occupation","Age","City","State"
"Joe","Smith","student",20,"Boston","MA"
1 First name Last name Occupation Age City State
2 Joe Smith student 20 Boston MA

View File

@@ -302,6 +302,23 @@ const stabilityChanged = async (Cypress, state, config, stable) => {
debug('waiting for window:load')
const promise = new Promise((resolve) => {
const handleDownloadUnloadEvent = () => {
cy.state('onPageLoadErr', null)
cy.isStable(true, 'download')
options._log
.set({
message: 'download fired beforeUnload event',
consoleProps () {
return {
Note: 'This event fired when the download was initiated.',
}
},
}).snapshot().end()
resolve()
}
const onWindowLoad = ({ url }) => {
const href = state('autLocation').href
const count = getRedirectionCount(href)
@@ -351,6 +368,7 @@ const stabilityChanged = async (Cypress, state, config, stable) => {
}
}
cy.once('download:received', handleDownloadUnloadEvent)
cy.once('internal:window:load', onInternalWindowLoad)
// If this request is still pending after the test run, resolve it, no commands were waiting on its result.

View File

@@ -712,6 +712,9 @@ class $Cypress {
case 'app:navigation:changed':
return this.emit('navigation:changed', ...args)
case 'app:download:received':
return this.emit('download:received')
case 'app:form:submitted':
return this.emit('form:submitted', args[0])

View File

@@ -25,11 +25,17 @@ export default {
return log.snapshot()
}
const end = ({ id }) => {
const end = ({ id }, isCanceled = false) => {
Cypress.action('app:download:received')
const log = logs[id]
if (log) {
log.snapshot().end()
if (isCanceled) {
log.snapshot().error(new Error('Download was canceled.'))
} else {
log.snapshot().end()
}
// don't need this anymore since the download has ended
// and won't change anymore

View File

@@ -51,11 +51,19 @@ const connect = function (host, path, extraOpts) {
})
browser.downloads.onChanged.addListener((downloadDelta) => {
if ((downloadDelta.state || {}).current !== 'complete') return
const state = (downloadDelta.state || {}).current
ws.emit('automation:push:request', 'complete:download', {
id: `${downloadDelta.id}`,
})
if (state === 'complete') {
ws.emit('automation:push:request', 'complete:download', {
id: `${downloadDelta.id}`,
})
}
if (state === 'canceled') {
ws.emit('automation:push:request', 'canceled:download', {
id: `${downloadDelta.id}`,
})
}
})
})

View File

@@ -231,6 +231,23 @@ describe('app/background', () => {
})
})
it('onChanged emits automation:push:request canceled:download', async function () {
const downloadDelta = {
id: '1',
state: {
current: 'canceled',
},
}
sinon.stub(browser.downloads.onChanged, 'addListener').yields(downloadDelta)
const ws = await this.connect()
expect(ws.emit).to.be.calledWith('automation:push:request', 'canceled:download', {
id: `${downloadDelta.id}`,
})
})
it('onChanged does not emit if state does not exist', async function () {
const downloadDelta = {
id: '1',

View File

@@ -127,6 +127,7 @@ export class Automation {
case 'change:cookie':
return this.cookies.changeCookie(data)
case 'create:download':
case 'canceled:download':
case 'complete:download':
return data
default:

View File

@@ -302,11 +302,17 @@ const _handleDownloads = async function (client, downloadsFolder: string, automa
})
client.on('Page.downloadProgress', (data) => {
if (data.state !== 'completed') return
if (data.state === 'completed') {
automation.push('complete:download', {
id: data.guid,
})
}
automation.push('complete:download', {
id: data.guid,
})
if (data.state === 'canceled') {
automation.push('canceled:download', {
id: data.guid,
})
}
})
await client.send('Page.setDownloadBehavior', {
@@ -528,13 +534,12 @@ export = {
await pageCriClient.send('Page.enable')
await utils.handleDownloadLinksViaCDP(pageCriClient, automation)
await options['onInitializeNewBrowserTab']?.()
await Promise.all([
options.videoApi && this._recordVideo(cdpAutomation, options.videoApi, Number(options.browser.majorVersion)),
this._handleDownloads(pageCriClient, options.downloadsFolder, automation),
utils.handleDownloadLinksViaCDP(pageCriClient, automation),
])
await this._navigateUsingCRI(pageCriClient, url)

View File

@@ -337,7 +337,7 @@ export = {
},
_handleDownloads (win, dir, automation) {
const onWillDownload = (event, downloadItem) => {
const onWillDownload = (_event, downloadItem) => {
const savePath = path.join(dir, downloadItem.getFilename())
automation.push('create:download', {
@@ -347,8 +347,14 @@ export = {
url: downloadItem.getURL(),
})
downloadItem.once('done', () => {
automation.push('complete:download', {
downloadItem.once('done', (_event, state) => {
if (state === 'completed') {
return automation.push('complete:download', {
id: downloadItem.getETag(),
})
}
automation.push('canceled:download', {
id: downloadItem.getETag(),
})
})

View File

@@ -338,6 +338,21 @@ describe('lib/browsers/chrome', () => {
})
})
})
it('pushes canceled:download when download is incomplete', function () {
const downloadData = {
guid: '1',
state: 'canceled',
}
const options = { downloadsFolder: 'downloads' }
return this.onCriEvent('Page.downloadProgress', downloadData, options)
.then(() => {
expect(this.automation.push).to.be.calledWith('canceled:download', {
id: '1',
})
})
})
})
describe('adding header to AUT iframe request', function () {

View File

@@ -322,7 +322,7 @@ describe('lib/browsers/electron', () => {
getFilename: () => 'file.csv',
getMimeType: () => 'text/csv',
getURL: () => 'http://localhost:1234/file.csv',
once: sinon.stub().yields(),
once: sinon.stub().yields({}, 'completed'),
}
this.win.webContents.session.on.withArgs('will-download').yields({}, downloadItem)
@@ -337,6 +337,27 @@ describe('lib/browsers/electron', () => {
})
})
it('pushes canceled:download when download is incomplete', function () {
const downloadItem = {
getETag: () => '1',
getFilename: () => 'file.csv',
getMimeType: () => 'text/csv',
getURL: () => 'http://localhost:1234/file.csv',
once: sinon.stub().yields({}, 'canceled'),
}
this.win.webContents.session.on.withArgs('will-download').yields({}, downloadItem)
this.options.downloadsFolder = 'downloads'
sinon.stub(this.automation, 'push')
return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() })
.then(() => {
expect(this.automation.push).to.be.calledWith('canceled:download', {
id: '1',
})
})
})
it('sets download behavior', function () {
this.options.downloadsFolder = 'downloads'