feat: Display command log entry for file downloads (#14749)

This commit is contained in:
Chris Breiding
2021-01-28 10:10:28 -05:00
committed by GitHub
parent 0bcccb588e
commit 67715f5321
16 changed files with 459 additions and 58 deletions
@@ -0,0 +1,64 @@
import { create } from '../../../src/cypress/downloads'
describe('src/cypress/downloads', () => {
let log
let snapshot
let end
let downloads
let downloadItem = {
id: '1',
filePath: '/path/to/save/location.csv',
url: 'http://localhost:1234/location.csv',
mime: 'text/csv',
}
beforeEach(() => {
end = cy.stub()
snapshot = cy.stub().returns({ end })
log = cy.stub().returns({ snapshot })
downloads = create({ log })
})
context('#start', () => {
it('creates snapshot for download', () => {
downloads.start(downloadItem)
expect(log).to.be.calledWithMatch({
message: downloadItem.filePath,
name: 'download',
type: 'parent',
event: true,
timeout: 0,
})
expect(snapshot).to.be.called
})
it('consoleProps include download url, save path, and mime type', () => {
downloads.start(downloadItem)
const consoleProps = log.lastCall.args[0].consoleProps()
expect(consoleProps).to.eql({
'Download URL': downloadItem.url,
'Saved To': downloadItem.filePath,
'Mime Type': downloadItem.mime,
})
})
})
context('#end', () => {
it('ends snapshot if matching log exists', () => {
downloads.start(downloadItem)
downloads.end({ id: '1' })
expect(end).to.be.called
})
it('is a noop if matching log does not exist', () => {
downloads.end({ id: '1' })
expect(end).not.to.be.called
// also just shouldn't error
})
})
})
+3
View File
@@ -24,6 +24,7 @@ const $LocalStorage = require('./cypress/local_storage')
const $Mocha = require('./cypress/mocha')
const $Mouse = require('./cy/mouse')
const $Runner = require('./cypress/runner')
const $Downloads = require('./cypress/downloads')
const $Server = require('./cypress/server')
const $Screenshot = require('./cypress/screenshot')
const $SelectorPlayground = require('./cypress/selector_playground')
@@ -54,6 +55,7 @@ class $Cypress {
this.chai = null
this.mocha = null
this.runner = null
this.downloads = null
this.Commands = null
this.$autIframe = null
this.onSpecReady = null
@@ -184,6 +186,7 @@ class $Cypress {
this.log = $Log.create(this, this.cy, this.state, this.config)
this.mocha = $Mocha.create(specWindow, this, this.config)
this.runner = $Runner.create(specWindow, this.mocha, this, this.cy)
this.downloads = $Downloads.create(this)
// wire up command create to cy
this.Commands = $Commands.create(this, this.cy, this.state, this.config)
+43
View File
@@ -0,0 +1,43 @@
export const create = (Cypress) => {
const logs = {}
const start = (downloadItem) => {
// store a reference to the download's log so we can retrieve it
// and end the snapshot later when it's done
const log = logs[downloadItem.id] = Cypress.log({
message: downloadItem.filePath,
name: 'download',
type: 'parent',
event: true,
timeout: 0,
consoleProps: () => {
const consoleObj = {
'Download URL': downloadItem.url,
'Saved To': downloadItem.filePath,
'Mime Type': downloadItem.mime,
}
return consoleObj
},
})
return log.snapshot()
}
const end = ({ id }) => {
const log = logs[id]
if (log) {
log.snapshot().end()
// don't need this anymore since the download has ended
// and won't change anymore
delete logs[id]
}
}
return {
start,
end,
}
}
+20
View File
@@ -27,6 +27,25 @@ const connect = function (host, path, extraOpts) {
})
})
const listenToDownloads = once(() => {
browser.downloads.onCreated.addListener((downloadItem) => {
ws.emit('automation:push:request', 'create:download', {
id: `${downloadItem.id}`,
filePath: downloadItem.filename,
mime: downloadItem.mime,
url: downloadItem.url,
})
})
browser.downloads.onChanged.addListener((downloadDelta) => {
if ((downloadDelta.state || {}).current !== 'complete') return
ws.emit('automation:push:request', 'complete:download', {
id: `${downloadDelta.id}`,
})
})
})
const fail = (id, err) => {
return ws.emit('automation:response', id, {
__error: err.message,
@@ -74,6 +93,7 @@ const connect = function (host, path, extraOpts) {
ws.on('connect', () => {
listenToCookieChanges()
listenToDownloads()
return ws.emit('automation:client:connected')
})
+1
View File
@@ -8,6 +8,7 @@
},
"permissions": [
"cookies",
"downloads",
"tabs",
"http://*/*",
"https://*/*",
@@ -14,6 +14,14 @@ const browser = {
addListener () {},
},
},
downloads: {
onCreated: {
addListener () {},
},
onChanged: {
addListener () {},
},
},
windows: {
getLastFocused () {},
},
@@ -101,14 +109,28 @@ describe('app/background', () => {
this.httpSrv = http.createServer()
this.server = socket.server(this.httpSrv, { path: '/__socket.io' })
return this.httpSrv.listen(PORT, done)
this.onConnect = (callback) => {
const client = background.connect(`http://localhost:${PORT}`, '/__socket.io')
client.on('connect', _.once(() => {
callback(client)
}))
}
this.stubEmit = (callback) => {
this.onConnect((client) => {
client.emit = _.once(callback)
})
}
this.httpSrv.listen(PORT, done)
})
afterEach(function (done) {
this.server.close()
return this.httpSrv.close(() => {
return done()
this.httpSrv.close(() => {
done()
})
})
@@ -145,38 +167,115 @@ describe('app/background', () => {
})
})
context('onChanged', () => {
it('does not emit when cause is overwrite', (done) => {
context('cookies', () => {
it('onChanged does not emit when cause is overwrite', function (done) {
const addListener = sinon.stub(browser.cookies.onChanged, 'addListener')
const client = background.connect(`http://localhost:${PORT}`, '/__socket.io')
sinon.spy(client, 'emit')
this.onConnect((client) => {
sinon.spy(client, 'emit')
return client.on('connect', _.once(() => {
const fn = addListener.getCall(0).args[0]
fn({ cause: 'overwrite' })
expect(client.emit).not.to.be.calledWith('automation:push:request')
return done()
}))
done()
})
})
it('emits \'automation:push:request\'', (done) => {
it('onChanged emits automation:push:request change:cookie', function (done) {
const info = { cause: 'explicit', cookie: { name: 'foo', value: 'bar' } }
sinon.stub(browser.cookies.onChanged, 'addListener').yieldsAsync(info)
const client = background.connect(`http://localhost:${PORT}`, '/__socket.io')
return client.on('connect', () => {
return client.emit = _.once((req, msg, data) => {
expect(req).to.eq('automation:push:request')
expect(msg).to.eq('change:cookie')
expect(data).to.deep.eq(info)
this.stubEmit((req, msg, data) => {
expect(req).to.eq('automation:push:request')
expect(msg).to.eq('change:cookie')
expect(data).to.deep.eq(info)
return done()
done()
})
})
})
context('downloads', () => {
it('onCreated emits automation:push:request create:download', function (done) {
const downloadItem = {
id: '1',
filename: '/path/to/download.csv',
mime: 'text/csv',
url: 'http://localhost:1234/download.csv',
}
sinon.stub(browser.downloads.onCreated, 'addListener').yieldsAsync(downloadItem)
this.stubEmit((req, msg, data) => {
expect(req).to.eq('automation:push:request')
expect(msg).to.eq('create:download')
expect(data).to.deep.eq({
id: `${downloadItem.id}`,
filePath: downloadItem.filename,
mime: downloadItem.mime,
url: downloadItem.url,
})
done()
})
})
it('onChanged emits automation:push:request complete:download', function (done) {
const downloadDelta = {
id: '1',
state: {
current: 'complete',
},
}
sinon.stub(browser.downloads.onChanged, 'addListener').yieldsAsync(downloadDelta)
this.stubEmit((req, msg, data) => {
expect(req).to.eq('automation:push:request')
expect(msg).to.eq('complete:download')
expect(data).to.deep.eq({ id: `${downloadDelta.id}` })
done()
})
})
it('onChanged does not emit if state does not exist', function (done) {
const downloadDelta = {
id: '1',
}
const addListener = sinon.stub(browser.downloads.onChanged, 'addListener')
this.onConnect((client) => {
sinon.spy(client, 'emit')
addListener.getCall(0).args[0](downloadDelta)
expect(client.emit).not.to.be.calledWith('automation:push:request')
done()
})
})
it('onChanged does not emit if state.current is not "complete"', function (done) {
const downloadDelta = {
id: '1',
state: {
current: 'inprogress',
},
}
const addListener = sinon.stub(browser.downloads.onChanged, 'addListener')
this.onConnect((client) => {
sinon.spy(client, 'emit')
addListener.getCall(0).args[0](downloadDelta)
expect(client.emit).not.to.be.calledWith('automation:push:request')
done()
})
})
})
+7 -1
View File
@@ -398,7 +398,13 @@
}
}
.command-name-log, .command-name-get {
.command-name-log,
.command-name-get,
.command-name-download {
// we're wrapping the text, so override command-scaled
font-size: 100%;
line-height: 18px;
.command-message-text {
white-space: initial;
word-wrap: break-word;
+6
View File
@@ -83,6 +83,12 @@ const eventManager = {
case 'change:cookie':
Cypress.Cookies.log(data.message, data.cookie, data.removed)
break
case 'create:download':
Cypress.downloads.start(data)
break
case 'complete:download':
Cypress.downloads.end(data)
break
default:
break
}
+3
View File
@@ -106,6 +106,9 @@ module.exports = {
return cookies.clearCookie(data, automate)
case 'change:cookie':
return cookies.changeCookie(data)
case 'create:download':
case 'complete:download':
return data
default:
return automate(data)
}
+34 -4
View File
@@ -6,6 +6,8 @@ import _ from 'lodash'
import os from 'os'
import path from 'path'
import extension from '@packages/extension'
import mime from 'mime'
import appData from '../util/app_data'
import fs from '../util/fs'
import { CdpAutomation } from './cdp_automation'
@@ -322,8 +324,36 @@ const _navigateUsingCRI = async function (client, url) {
await client.send('Page.navigate', { url })
}
const _setDownloadsDir = async function (client, dir) {
await client.send('Page.setDownloadBehavior', {
const _handleDownloads = async function (client, dir, automation) {
await client.send('Page.enable')
client.on('Page.downloadWillBegin', (data) => {
const downloadItem = {
id: data.guid,
url: data.url,
}
const filename = data.suggestedFilename
if (filename) {
// @ts-ignore
downloadItem.filePath = path.join(dir, data.suggestedFilename)
// @ts-ignore
downloadItem.mime = mime.getType(data.suggestedFilename)
}
automation.push('create:download', downloadItem)
})
client.on('Page.downloadProgress', (data) => {
if (data.state !== 'completed') return
automation.push('complete:download', {
id: data.guid,
})
})
await client.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: dir,
})
@@ -352,7 +382,7 @@ export = {
_navigateUsingCRI,
_setDownloadsDir,
_handleDownloads,
_setAutomation,
@@ -528,7 +558,7 @@ export = {
await this._maybeRecordVideo(criClient, options)
await this._navigateUsingCRI(criClient, url)
await this._setDownloadsDir(criClient, options.downloadsFolder)
await this._handleDownloads(criClient, options.downloadsFolder, automation)
// return the launched browser process
// with additional method to close the remote connection
+5 -2
View File
@@ -30,10 +30,13 @@ namespace CRI {
'Page.captureScreenshot' |
'Page.navigate' |
'Page.startScreencast' |
'Page.screencastFrameAck'
'Page.screencastFrameAck' |
'Browser.setDownloadBehavior'
export type EventName =
'Page.screencastFrame'
'Page.screencastFrame' |
'Page.downloadWillBegin' |
'Page.downloadProgress'
}
/**
+25 -7
View File
@@ -1,5 +1,6 @@
const _ = require('lodash')
const EE = require('events')
const path = require('path')
const Bluebird = require('bluebird')
const debug = require('debug')('cypress:server:browsers:electron')
const menu = require('../gui/menu')
@@ -69,11 +70,11 @@ const _getAutomation = function (win, options) {
const _installExtensions = function (win, extensionPaths = [], options) {
Windows.removeAllExtensions(win)
return Bluebird.map(extensionPaths, (path) => {
return Bluebird.map(extensionPaths, (extensionPath) => {
try {
return Windows.installExtension(win, path)
return Windows.installExtension(win, extensionPath)
} catch (error) {
return options.onWarning(errors.get('EXTENSION_NOT_LOADED', 'Electron', path))
return options.onWarning(errors.get('EXTENSION_NOT_LOADED', 'Electron', extensionPath))
}
})
}
@@ -162,7 +163,7 @@ module.exports = {
automation.use(_getAutomation(win, options))
return this._launch(win, url, options)
return this._launch(win, url, automation, options)
.tap(_maybeRecordVideo(win.webContents, options))
},
@@ -188,7 +189,7 @@ module.exports = {
return this._launch(win, url, options)
},
_launch (win, url, options) {
_launch (win, url, automation, options) {
if (options.show) {
menu.set({ withDevTools: true })
}
@@ -234,7 +235,7 @@ module.exports = {
return this._enableDebugger(win.webContents)
})
.then(() => {
return this._setDownloadsDir(win.webContents, options.downloadsFolder)
return this._handleDownloads(win.webContents, options.downloadsFolder, automation)
})
.return(win)
},
@@ -290,7 +291,24 @@ module.exports = {
return webContents.debugger.sendCommand('Console.enable')
},
_setDownloadsDir (webContents, dir) {
_handleDownloads (webContents, dir, automation) {
webContents.session.on('will-download', (event, downloadItem) => {
const savePath = path.join(dir, downloadItem.getFilename())
automation.push('create:download', {
id: downloadItem.getETag(),
filePath: savePath,
mime: downloadItem.getMimeType(),
url: downloadItem.getURL(),
})
downloadItem.once('done', () => {
automation.push('complete:download', {
id: downloadItem.getETag(),
})
})
})
return webContents.debugger.sendCommand('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: dir,
@@ -1126,6 +1126,7 @@ describe('lib/cypress', () => {
clearCache: sinon.stub().resolves(),
setProxy: sinon.stub().resolves(),
setUserAgent: sinon.stub(),
on: sinon.stub(),
},
}
@@ -1147,7 +1148,7 @@ describe('lib/cypress', () => {
// it accepts URL to visit and then waits for actual CRI client reference
// and only then navigates to that URL
sinon.stub(chromeBrowser, '_navigateUsingCRI').resolves()
sinon.stub(chromeBrowser, '_setDownloadsDir').resolves()
sinon.stub(chromeBrowser, '_handleDownloads').resolves()
sinon.stub(chromeBrowser, '_setAutomation').returns()
@@ -19,9 +19,11 @@ describe('lib/browsers/chrome', () => {
screencastFrame: sinon.stub().returns(),
},
close: sinon.stub().resolves(),
on: sinon.stub(),
}
this.automation = {
push: sinon.stub(),
use: sinon.stub().returns(),
}
@@ -30,6 +32,15 @@ describe('lib/browsers/chrome', () => {
kill: sinon.stub().returns(),
}
this.onCriEvent = (event, data, options) => {
this.criClient.on.withArgs(event).yieldsAsync(data)
return chrome.open('chrome', 'http://', options, this.automation)
.then(() => {
this.criClient.on = undefined
})
}
sinon.stub(chrome, '_writeExtension').resolves('/path/to/ext')
sinon.stub(chrome, '_connectToChromeRemoteInterface').resolves(this.criClient)
sinon.stub(plugins, 'execute').callThrough()
@@ -43,22 +54,23 @@ describe('lib/browsers/chrome', () => {
this.readJson.withArgs('/profile/dir/Local State').rejects({ code: 'ENOENT' })
// port for Chrome remote interface communication
return sinon.stub(utils, 'getPort').resolves(50505)
sinon.stub(utils, 'getPort').resolves(50505)
})
afterEach(function () {
expect(this.criClient.ensureMinimumProtocolVersion).to.be.calledOnce
})
it('focuses on the page, calls CRI Page.visit, and sets download behavior', function () {
it('focuses on the page, calls CRI Page.visit, enables Page events, and sets download behavior', function () {
return chrome.open('chrome', 'http://', {}, this.automation)
.then(() => {
expect(utils.getPort).to.have.been.calledOnce // to get remote interface port
expect(this.criClient.send).to.have.been.calledThrice
expect(this.criClient.send.callCount).to.equal(4)
expect(this.criClient.send).to.have.been.calledWith('Page.bringToFront')
expect(this.criClient.send).to.have.been.calledWith('Page.navigate')
expect(this.criClient.send).to.have.been.calledWith('Page.setDownloadBehavior')
expect(this.criClient.send).to.have.been.calledWith('Page.enable')
expect(this.criClient.send).to.have.been.calledWith('Browser.setDownloadBehavior')
})
})
@@ -261,23 +273,50 @@ describe('lib/browsers/chrome', () => {
// https://github.com/cypress-io/cypress/issues/9265
it('respond ACK after receiving new screenshot frame', function () {
const frameMeta = { data: Buffer.from(''), sessionId: '1' }
this.criClient.on = (eventName, fn) => {
if (eventName === 'Page.screencastFrame') {
fn(frameMeta)
}
}
const write = sinon.stub()
const options = { onScreencastFrame: write }
return chrome.open('chrome', 'http://', { onScreencastFrame: write }, this.automation)
return this.onCriEvent('Page.screencastFrame', frameMeta, options)
.then(() => {
expect(this.criClient.send).to.have.been.calledWith('Page.startScreencast')
expect(write).to.have.been.calledWith(frameMeta)
expect(this.criClient.send).to.have.been.calledWith('Page.screencastFrameAck', { sessionId: frameMeta.sessionId })
})
.then(() => {
this.criClient.on = undefined
})
describe('downloads', function () {
it('pushes create:download after download begins', function () {
const downloadData = {
guid: '1',
suggestedFilename: 'file.csv',
url: 'http://localhost:1234/file.csv',
}
const options = { downloadsFolder: 'downloads' }
return this.onCriEvent('Page.downloadWillBegin', downloadData, options)
.then(() => {
expect(this.automation.push).to.be.calledWith('create:download', {
id: '1',
filePath: 'downloads/file.csv',
mime: 'text/csv',
url: 'http://localhost:1234/file.csv',
})
})
})
it('pushes complete:download after download completes', function () {
const downloadData = {
guid: '1',
state: 'completed',
}
const options = { downloadsFolder: 'downloads' }
return this.onCriEvent('Page.downloadProgress', downloadData, options)
.then(() => {
expect(this.automation.push).to.be.calledWith('complete:download', {
id: '1',
})
})
})
})
})
@@ -39,6 +39,7 @@ describe('lib/browsers/electron', () => {
set: sinon.stub(),
remove: sinon.stub(),
},
on: sinon.stub(),
},
getOSProcessId: sinon.stub().returns(ELECTRON_PID),
'debugger': {
@@ -165,58 +166,114 @@ describe('lib/browsers/electron', () => {
sinon.stub(electron, '_attachDebugger').resolves()
sinon.stub(electron, '_clearCache').resolves()
sinon.stub(electron, '_setProxy').resolves()
return sinon.stub(electron, '_setUserAgent')
sinon.stub(electron, '_setUserAgent')
})
it('sets menu.set whether or not its in headless mode', function () {
return electron._launch(this.win, this.url, { show: true })
return electron._launch(this.win, this.url, this.automation, { show: true })
.then(() => {
expect(menu.set).to.be.calledWith({ withDevTools: true })
}).then(() => {
menu.set.reset()
return electron._launch(this.win, this.url, { show: false })
return electron._launch(this.win, this.url, this.automation, { show: false })
}).then(() => {
expect(menu.set).not.to.be.called
})
})
it('sets user agent if options.userAgent', function () {
return electron._launch(this.win, this.url, this.options)
return electron._launch(this.win, this.url, this.automation, this.options)
.then(() => {
expect(electron._setUserAgent).not.to.be.called
}).then(() => {
return electron._launch(this.win, this.url, { userAgent: 'foo' })
return electron._launch(this.win, this.url, this.automation, { userAgent: 'foo' })
}).then(() => {
expect(electron._setUserAgent).to.be.calledWith(this.win.webContents, 'foo')
})
})
it('sets proxy if options.proxyServer', function () {
return electron._launch(this.win, this.url, this.options)
return electron._launch(this.win, this.url, this.automation, this.options)
.then(() => {
expect(electron._setProxy).not.to.be.called
}).then(() => {
return electron._launch(this.win, this.url, { proxyServer: 'foo' })
return electron._launch(this.win, this.url, this.automation, { proxyServer: 'foo' })
}).then(() => {
expect(electron._setProxy).to.be.calledWith(this.win.webContents, 'foo')
})
})
it('calls win.loadURL with url', function () {
return electron._launch(this.win, this.url, this.options)
return electron._launch(this.win, this.url, this.automation, this.options)
.then(() => {
expect(this.win.loadURL).to.be.calledWith(this.url)
})
})
it('resolves with win', function () {
return electron._launch(this.win, this.url, this.options)
return electron._launch(this.win, this.url, this.automation, this.options)
.then((win) => {
expect(win).to.eq(this.win)
})
})
it('pushes create:download when download begins', function () {
const downloadItem = {
getETag: () => '1',
getFilename: () => 'file.csv',
getMimeType: () => 'text/csv',
getURL: () => 'http://localhost:1234/file.csv',
once: sinon.stub(),
}
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)
.then(() => {
expect(this.automation.push).to.be.calledWith('create:download', {
id: '1',
filePath: 'downloads/file.csv',
mime: 'text/csv',
url: 'http://localhost:1234/file.csv',
})
})
})
it('pushes complete:download when download is done', function () {
const downloadItem = {
getETag: () => '1',
getFilename: () => 'file.csv',
getMimeType: () => 'text/csv',
getURL: () => 'http://localhost:1234/file.csv',
once: sinon.stub().yields(),
}
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)
.then(() => {
expect(this.automation.push).to.be.calledWith('complete:download', {
id: '1',
})
})
})
it('sets download behavior', function () {
this.options.downloadsFolder = 'downloads'
return electron._launch(this.win, this.url, this.automation, this.options)
.then(() => {
expect(this.win.webContents.debugger.sendCommand).to.be.calledWith('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: 'downloads',
})
})
})
})
context('._render', () => {
@@ -237,7 +294,7 @@ describe('lib/browsers/electron', () => {
.then(() => {
expect(Windows.create).to.be.calledWith(this.options.projectRoot, this.options)
expect(electron._launch).to.be.calledWith(this.newWin, this.url, this.options)
expect(electron._launch).to.be.calledWith(this.newWin, this.url, this.automation, this.options)
})
})
+8
View File
@@ -112,6 +112,14 @@ describe('lib/socket', () => {
addListener () {},
},
},
downloads: {
onCreated: {
addListener () {},
},
onChanged: {
addListener () {},
},
},
runtime: {
},