feat: Add notification in Desktop GUI when there is a new version available (#8803)

Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
This commit is contained in:
Chris Breiding
2020-10-13 10:05:25 -04:00
committed by GitHub
parent 66d1a5210c
commit 4db37e946d
16 changed files with 494 additions and 176 deletions
+1
View File
@@ -10,6 +10,7 @@
"nodeVersion": "system",
"testFiles": "**/*_spec.{js,jsx}",
"experimentalComponentTesting": true,
"experimentalNetworkStubbing": true,
"componentFolder": "src",
"reporter": "../../node_modules/cypress-multi-reporters/index.js",
"reporterOptions": {
@@ -303,7 +303,7 @@ describe('Login', function () {
describe('api help link', () => {
it('goes to external api help link', () => {
cy.contains('Learn more').click().then(function () {
cy.get('.login').contains('Learn more').click().then(function () {
expect(this.ipc.externalOpen).to.be.calledWith('https://on.cypress.io/help-connect-to-api')
})
})
@@ -0,0 +1,168 @@
import human from 'human-interval'
import { deferred } from '../support/util'
const OLD_VERSION = '1.3.3'
const NEW_VERSION = '1.3.4'
describe('Update Modal', () => {
let user
let start
let ipc
let updaterCheck
beforeEach(() => {
cy.viewport(800, 500)
cy.fixture('user').then((theUser) => user = theUser)
cy.fixture('projects').as('projects')
cy.fixture('config').as('config')
cy.visitIndex({
onBeforeLoad (win) {
cy.spy(win, 'setInterval')
},
}).then((win) => {
start = win.App.start
ipc = win.App.ipc
cy.stub(ipc, 'getCurrentUser').resolves(user)
cy.stub(ipc, 'externalOpen')
cy.stub(ipc, 'setClipboardText')
updaterCheck = deferred()
cy.stub(ipc, 'updaterCheck').returns(updaterCheck.promise)
})
})
describe('general behavior', () => {
beforeEach(() => {
cy.stub(ipc, 'getOptions').resolves({ version: OLD_VERSION })
start()
})
it('checks for updates every 60 minutes', () => {
cy.window().then((win) => {
expect(win.setInterval.firstCall.args[1]).to.eq(human('60 minutes'))
})
})
it('checks for update on show', () => {
cy.wrap(ipc.updaterCheck).should('be.called')
})
it('gracefully handles error', () => {
updaterCheck.reject({ name: 'foo', message: 'Something bad happened' })
cy.get('.footer').should('be.visible')
})
it('opens modal on click of Update link', () => {
updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
cy.get('.modal').should('be.visible')
})
it('closes modal when X is clicked', () => {
updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
cy.get('.modal').find('.close').click()
cy.get('.modal').should('not.be.visible')
})
})
describe('in global mode', () => {
beforeEach(() => {
cy.stub(ipc, 'getOptions').resolves({ version: OLD_VERSION, os: 'linux' })
start()
updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
})
it('modal has info about downloading new version', () => {
cy.get('.modal').contains('Download the new version')
})
it('opens download link when Download is clicked', () => {
cy.contains('Download the new version').click().then(() => {
expect(ipc.externalOpen).to.be.calledWith('https://download.cypress.io/desktop')
})
})
})
describe('in project mode', () => {
const npmCommand = `npm install --save-dev cypress@${NEW_VERSION}`
const yarnCommand = `yarn upgrade cypress@${NEW_VERSION}`
beforeEach(() => {
cy.stub(ipc, 'getOptions').resolves({ version: OLD_VERSION, projectRoot: '/foo/bar' })
start()
updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
})
it('modal has info about upgrading via package manager', () => {
cy.get('.modal').contains(npmCommand)
cy.get('.modal').contains(yarnCommand)
cy.percySnapshot()
})
it('copies npm upgrade command to clipboard', () => {
cy.contains(npmCommand).find('button').click()
.then(() => {
expect(ipc.setClipboardText).to.be.calledWith(npmCommand)
})
})
it('changes npm upgrade button icon after copying', () => {
cy.contains(npmCommand).find('button').click()
cy.contains(npmCommand).find('button i').should('have.class', 'fa-check')
})
it('disables npm upgrade button after copying', () => {
cy.contains(npmCommand).find('button').click().should('be.disabled')
})
it('resets npm upgrade button after 5 seconds', () => {
cy.clock()
cy.contains(npmCommand).find('button').click()
cy.tick(5000)
cy.contains(npmCommand).find('button i').should('have.class', 'fa-copy')
cy.contains(npmCommand).find('button').should('not.be.disabled')
})
it('copies yarn upgrade command to clipboard', () => {
cy.contains(yarnCommand).find('button').click()
.then(() => {
expect(ipc.setClipboardText).to.be.calledWith(yarnCommand)
})
})
it('changes yarn upgrade button icon after copying', () => {
cy.contains(yarnCommand).find('button').click()
cy.contains(yarnCommand).find('button i').should('have.class', 'fa-check')
})
it('disables yarn upgrade button after copying', () => {
cy.contains(yarnCommand).find('button').click().should('be.disabled')
})
it('resets yarn upgrade button after 5 seconds', () => {
cy.clock()
cy.contains(yarnCommand).find('button').click()
cy.tick(5000)
cy.contains(yarnCommand).find('button i').should('have.class', 'fa-copy')
cy.contains(yarnCommand).find('button').should('not.be.disabled')
})
it('links to \'open\' doc on click of open command', () => {
cy.contains('cypress open').click().then(() => {
expect(ipc.externalOpen).to.be.calledWith('https://on.cypress.io/how-to-open-cypress')
})
})
})
})
@@ -0,0 +1,90 @@
import human from 'human-interval'
import { deferred } from '../support/util'
describe('Update Notice', () => {
let user
let ipc
let start
let updaterCheck
beforeEach(() => {
cy.viewport(800, 500)
cy.fixture('user').then((theUser) => user = theUser)
cy.fixture('projects').as('projects')
cy.fixture('config').as('config')
cy.visitIndex().then((win) => {
ipc = win.App.ipc
start = win.App.start
cy.stub(ipc, 'getCurrentUser').resolves(user)
cy.stub(ipc, 'getOptions').resolves({ version: '1.0.0' })
updaterCheck = deferred()
cy.stub(ipc, 'updaterCheck').returns(updaterCheck.promise)
})
})
it('does not appear if up-to-date', () => {
start()
cy.wait(500) // need to wait for animation or it will falsely appear invisible
cy.get('.update-notice').should('not.be.visible')
})
describe('when there is an update', () => {
beforeEach(() => {
start()
updaterCheck.resolve('1.0.1')
})
it('shows update notice', () => {
cy.get('.update-notice').should('be.visible')
cy.get('.update-notice .content').should('have.text', 'An update (1.0.1) is available. Learn more')
cy.percySnapshot()
})
it('clicking on "Learn more" opens update modal and closes notice', () => {
cy.get('.update-notice').contains('Learn more').click()
cy.get('.update-modal').should('be.visible')
cy.get('.update-notice').should('not.be.visible')
})
it('clicking close button closes notice', () => {
cy.get('.update-notice .notification-close').click()
cy.get('.update-notice').should('not.be.visible')
})
})
describe('when there is an update that has already been dismissed', () => {
let updaterCheck2
beforeEach(() => {
cy.clock()
cy.window().then((win) => {
win.localStorage.setItem('dismissed-update-version', JSON.stringify('1.0.1'))
updaterCheck2 = deferred()
ipc.updaterCheck.onCall(1).returns(updaterCheck2.promise)
start()
updaterCheck.resolve('1.0.1')
})
})
it('does not show update notice', () => {
cy.wait(500) // need to wait for animation or it will falsely appear invisible
cy.get('.update-notice').should('not.be.visible')
})
it('shows update notice when a newer version is available', () => {
cy.tick(human('60 minutes')).then(() => {
updaterCheck2.resolve('1.0.2')
})
cy.get('.update-notice').should('be.visible')
cy.get('.update-notice .content').should('have.text', 'An update (1.0.2) is available. Learn more')
})
})
})
@@ -1,164 +0,0 @@
const human = require('human-interval')
const OLD_VERSION = '1.3.3'
const NEW_VERSION = '1.3.4'
describe('Updates', function () {
beforeEach(function () {
cy.viewport(800, 500)
cy.fixture('user').as('user')
cy.fixture('projects').as('projects')
cy.fixture('projects_statuses').as('projectStatuses')
cy.fixture('config').as('config')
cy.fixture('specs').as('specs')
cy.visitIndex({
onBeforeLoad (win) {
cy.spy(win, 'setInterval')
},
}).then(function (win) {
({ start: this.start, ipc: this.ipc } = win.App)
cy.stub(this.ipc, 'getCurrentUser').resolves(this.user)
cy.stub(this.ipc, 'windowOpen')
cy.stub(this.ipc, 'externalOpen')
cy.stub(this.ipc, 'setClipboardText')
this.updaterCheck = this.util.deferred()
cy.stub(this.ipc, 'updaterCheck').returns(this.updaterCheck.promise)
})
})
describe('general behavior', function () {
beforeEach(function () {
cy.stub(this.ipc, 'getOptions').resolves({ version: OLD_VERSION })
this.start()
})
it('checks for updates every 60 minutes', () => {
cy.window().then((win) => {
expect(win.setInterval.firstCall.args[1]).to.eq(human('60 minutes'))
})
})
it('checks for update on show', function () {
cy.wrap(this.ipc.updaterCheck).should('be.called')
})
it('gracefully handles error', function () {
this.updaterCheck.reject({ name: 'foo', message: 'Something bad happened' })
cy.get('.footer').should('be.visible')
})
it('opens modal on click of Update link', function () {
this.updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
cy.get('.modal').should('be.visible')
})
it('closes modal when X is clicked', function () {
this.updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
cy.get('.modal').find('.close').click()
cy.get('.modal').should('not.be.visible')
})
})
describe('in global mode', function () {
beforeEach(function () {
cy.stub(this.ipc, 'getOptions').resolves({ version: OLD_VERSION, os: 'linux' })
this.start()
this.updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
})
it('modal has info about downloading new version', () => {
cy.get('.modal').contains('Download the new version')
})
it('opens download link when Download is clicked', function () {
cy.contains('Download the new version').click().then(() => {
expect(this.ipc.externalOpen).to.be.calledWith('https://download.cypress.io/desktop')
})
})
})
describe('in project mode', function () {
const npmCommand = `npm install --save-dev cypress@${NEW_VERSION}`
const yarnCommand = `yarn upgrade cypress@${NEW_VERSION}`
beforeEach(function () {
cy.stub(this.ipc, 'getOptions').resolves({ version: OLD_VERSION, projectRoot: '/foo/bar' })
this.start()
this.updaterCheck.resolve(NEW_VERSION)
cy.get('.footer .version').click()
})
it('modal has info about upgrading via package manager', function () {
cy.get('.modal').contains(npmCommand)
cy.get('.modal').contains(yarnCommand)
cy.percySnapshot()
})
it('copies npm upgrade command to clipboard', function () {
cy.contains(npmCommand).find('button').click()
.then(() => {
expect(this.ipc.setClipboardText).to.be.calledWith(npmCommand)
})
})
it('changes npm upgrade button icon after copying', function () {
cy.contains(npmCommand).find('button').click()
cy.contains(npmCommand).find('button i').should('have.class', 'fa-check')
})
it('disables npm upgrade button after copying', function () {
cy.contains(npmCommand).find('button').click().should('be.disabled')
})
it('resets npm upgrade button after 5 seconds', function () {
cy.clock()
cy.contains(npmCommand).find('button').click()
cy.tick(5000)
cy.contains(npmCommand).find('button i').should('have.class', 'fa-copy')
cy.contains(npmCommand).find('button').should('not.be.disabled')
})
it('copies yarn upgrade command to clipboard', function () {
cy.contains(yarnCommand).find('button').click()
.then(() => {
expect(this.ipc.setClipboardText).to.be.calledWith(yarnCommand)
})
})
it('changes yarn upgrade button icon after copying', function () {
cy.contains(yarnCommand).find('button').click()
cy.contains(yarnCommand).find('button i').should('have.class', 'fa-check')
})
it('disables yarn upgrade button after copying', function () {
cy.contains(yarnCommand).find('button').click().should('be.disabled')
})
it('resets yarn upgrade button after 5 seconds', function () {
cy.clock()
cy.contains(yarnCommand).find('button').click()
cy.tick(5000)
cy.contains(yarnCommand).find('button i').should('have.class', 'fa-copy')
cy.contains(yarnCommand).find('button').should('not.be.disabled')
})
it('links to \'open\' doc on click of open command', function () {
cy.contains('cypress open').click().then(() => {
expect(this.ipc.externalOpen).to.be.calledWith('https://on.cypress.io/how-to-open-cypress')
})
})
})
})
@@ -23,6 +23,11 @@ beforeEach(function () {
})
Cypress.Commands.add('visitIndex', (options = {}) => {
// disable livereload within the Cypress-loaded desktop GUI. it doesn't fully
// reload the app because the stubbed out ipc calls don't work after the first
// time, so it ends up a useless white page
cy.route2({ path: /livereload/ }, '')
return cy.visit('/', options)
})
@@ -0,0 +1,16 @@
const BluebirdPromise = require('bluebird')
export const deferred = (Promise = BluebirdPromise) => {
const deferred = {}
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve
deferred.reject = reject
})
return deferred
}
export const deepClone = (obj) => {
return JSON.parse(JSON.stringify(obj))
}
+2 -1
View File
@@ -12,7 +12,7 @@
"dev": "yarn start-test 5005 cypress:open",
"postinstall": "echo '@packages/desktop-gui needs: yarn build'",
"start": "http-server -p 5005 dist",
"watch": "yarn build -- --watch --progress"
"watch": "yarn build --watch --progress"
},
"devDependencies": {
"@babel/polyfill": "7.8.7",
@@ -45,6 +45,7 @@
"react-inspector": "5.0.1",
"react-loader": "2.4.7",
"react-select": "3.1.0",
"react-transition-group": "4.4.1",
"webpack": "4.35.3",
"webpack-cli": "3.3.2"
},
@@ -8,6 +8,7 @@ import appStore from '../lib/app-store'
import { useUpdateChecker } from '../update/use-update-checker'
import UpdateModal from '../update/update-modal'
import UpdateNotice from '../update/update-notice'
const openChangelog = (e) => {
e.target.blur()
@@ -44,6 +45,7 @@ const Footer = observer(() => {
</button>
<button className='open-changelog' onClick={openChangelog}>Changelog</button>
<UpdateModal show={state.showingModal} onClose={state.hideModal} />
<UpdateNotice onOpenUpdatesModal={state.showModal} />
</footer>
)
})
+10
View File
@@ -8,6 +8,7 @@ class AppStore {
@observable newVersion
@observable version
@observable localInstallNoticeDismissed = localData.get('local-install-notice-dimissed')
@observable dismissedUpdateVersion = localData.get('dismissed-update-version')
@observable error
@observable proxyServer
@observable proxyBypassList
@@ -35,6 +36,10 @@ class AppStore {
return this.version !== this.newVersion
}
@computed get nonDismissedUpdateAvailable () {
return this.updateAvailable && this.newVersion !== this.dismissedUpdateVersion
}
@action set (props) {
if (props.cypressEnv != null) this.cypressEnv = props.cypressEnv
@@ -58,6 +63,11 @@ class AppStore {
localData.set('local-install-notice-dimissed', isDismissed)
}
@action setDismissedUpdateVersion () {
this.dismissedUpdateVersion = this.newVersion
localData.set('dismissed-update-version', this.newVersion)
}
@action setError (err) {
this.error = err
}
@@ -0,0 +1,25 @@
import cs from 'classnames'
import React from 'react'
import { CSSTransition } from 'react-transition-group'
import { Portal } from '@packages/ui-components'
const Notification = ({ children, className, onClose, show }) => {
return (
<Portal>
<CSSTransition in={show} timeout={500} classNames='notification'>
<div className={cs('notification', className)}>
<div className='notification-wrap'>
<div className='content'>
{children}
</div>
<button className='notification-close' onClick={onClose}>
<i className='fas fa-times' />
</button>
</div>
</div>
</CSSTransition>
</Portal>
)
}
export default Notification
@@ -0,0 +1,99 @@
.notification {
bottom: 4.8rem;
padding-right: 1.2rem;
position: fixed;
right: 0;
z-index: 1020;
.notification-wrap {
align-items: center;
background: #252831;
border-radius: 3px;
display: flex;
padding: 0.8rem 1.2rem 0.8rem 1.5rem;
}
.content {
align-items: center;
color: #EFF0F3;
display: flex;
font-size: 1.3rem;
i {
color: #5393E9;
font-size: 2.2rem;
margin-right: 1rem;
}
a {
color: #8EBCF9;
margin-left: 1rem;
&:hover,
&:focus {
color: #accdf9;
}
}
}
.notification-close {
background: none;
border: none;
color: #8A8C92;
font-size: 2.4rem;
margin-left: 3rem;
padding: 0 0.4rem;
&:hover,
&:focus {
color: #fff;
}
}
// transitions for react-transition-group
// slide in from right on enter, fade/shrink away on exit
&,
&-enter {
transform: translate(110%);
}
&-enter {
opacity: 1;
}
&-enter-active {
transform: translate(0);
transition: transform 500ms ease-out;
}
&-enter-done {
transform: translate(0);
}
&-exit {
opacity: 1;
transform: translate(0);
}
&-exit-active {
opacity: 0;
transform: translate(0) scale(0.7);
transition: all 300ms ease-out;
}
&-exit-done {
opacity: 0;
transform: translate(110%);
}
}
.notification-exit-done.notification-exit-active {
background: red;
}
.open-notice {
position: fixed;
bottom: 16rem;
right: 1rem;
z-index: 9999;
}
@@ -0,0 +1,28 @@
import React from 'react'
import { observer } from 'mobx-react'
import appStore from '../lib/app-store'
import Notification from '../notifications/notification'
const UpdateNotice = observer(({ onOpenUpdatesModal }) => {
const onClose = () => {
appStore.setDismissedUpdateVersion()
}
const onLearnMore = (e) => {
e.preventDefault()
appStore.setDismissedUpdateVersion()
onOpenUpdatesModal()
}
return (
<Notification className='update-notice' show={appStore.nonDismissedUpdateAvailable} onClose={onClose}>
<i className='fas fa-shipping-fast' />
An update ({appStore.newVersion}) is available.{' '}
<a href='#' onClick={onLearnMore}>Learn more</a>
</Notification>
)
})
export default UpdateNotice
+2
View File
@@ -2,6 +2,8 @@ export { default as BrowserIcon } from './browser-icon'
export { default as Dropdown } from './dropdown'
export { default as Portal } from './portal'
export { default as EditorPicker } from './file-opener/editor-picker'
export { default as EditorPickerModal } from './file-opener/editor-picker-modal'
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
let idNum = 0
const Portal = ({ children }) => {
const [id, setId] = useState(null)
useEffect(() => () => {
// remove element on unmount. use a try/catch because it's possible
// the element was removed from the dom or the dom has been blown
// away, which will cause `removeChild` to throw an exception
try {
document.removeChild(element)
} catch (err) {} // eslint-disable-line no-empty
}, [true])
if (!id) {
setId(`cy-desktop-gui-portal-${idNum++}`)
return null
}
let element = document.getElementById(id)
if (!element) {
element = document.createElement('div')
element.id = id
document.body.appendChild(element)
}
return createPortal(children, element)
}
export default Portal
+10 -10
View File
@@ -25797,6 +25797,16 @@ react-test-renderer@^16.0.0-0:
react-is "^16.8.6"
scheduler "^0.19.1"
react-transition-group@4.4.1, react-transition-group@^4.3.0:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-transition-group@^2.0.0, react-transition-group@^2.2.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
@@ -25807,16 +25817,6 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.0:
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
react-transition-group@^4.3.0:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react@16.13.1, react@^16.0.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"