diff --git a/npm/webpack-preprocessor/package.json b/npm/webpack-preprocessor/package.json index cbbe2e415b58..87ca16cbae8a 100644 --- a/npm/webpack-preprocessor/package.json +++ b/npm/webpack-preprocessor/package.json @@ -6,7 +6,7 @@ "main": "dist", "scripts": { "ban": "ban", - "build": "rm -rf dist && tsc", + "build": "shx rm -rf dist && tsc", "build-prod": "yarn build", "deps": "deps-ok && dependency-check --no-dev .", "license": "license-checker --production --onlyunknown --csv", @@ -58,6 +58,7 @@ "react-dom": "16.13.1", "react-scripts": "3.2", "semantic-release": "17.0.4", + "shx": "0.3.3", "sinon": "^9.0.0", "sinon-chai": "^3.5.0", "snap-shot-it": "7.9.2", diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.js b/packages/desktop-gui/cypress/integration/specs_list_spec.js index 24a4153a4707..ca9c08e85682 100644 --- a/packages/desktop-gui/cypress/integration/specs_list_spec.js +++ b/packages/desktop-gui/cypress/integration/specs_list_spec.js @@ -27,6 +27,7 @@ describe('Specs List', function () { cy.stub(this.ipc, 'onboardingClosed') cy.stub(this.ipc, 'onSpecChanged') cy.stub(this.ipc, 'setUserEditor') + cy.stub(this.ipc, 'showNewSpecDialog').resolves({ specs: null, path: null }) this.openProject = this.util.deferred() cy.stub(this.ipc, 'openProject').returns(this.openProject.promise) @@ -989,4 +990,144 @@ describe('Specs List', function () { }) }) }) + + describe('new spec file', function () { + beforeEach(function () { + this.openProject.resolve(this.config) + }) + + it('launches system save dialog', function () { + cy.contains('New Spec File').click().then(function () { + expect(this.ipc.showNewSpecDialog).to.be.called + }) + }) + + context('POSIX paths', function () { + context('when file is created within project path', function () { + beforeEach(function () { + this.newSpec = { + name: 'new_spec.js', + absolute: '/user/project/cypress/integration/new_spec.js', + relative: 'cypress/integration/new_spec.js', + } + + this.ipc.showNewSpecDialog.resolves({ + specs: { ...this.specs, integration: this.specs.integration.concat(this.newSpec) }, + path: this.newSpec.absolute, + }) + }) + + it('adds and highlights new spec item', function () { + cy.contains('New Spec File').click() + cy.contains('new_spec.js').closest('.file').should('have.class', 'new-spec') + }) + + it('scrolls the new spec item into view', function () { + cy.contains('New Spec File').click() + cy.contains('new_spec.js').closest('.file').then(function ($el) { + cy.stub($el[0], 'scrollIntoView') + cy.contains('New Spec File').click() + cy.wrap($el[0].scrollIntoView).should('be.called') + }) + }) + + it('does not display warning message', function () { + cy.contains('New Spec File').click() + cy.contains('Your file has been successfully created').should('not.be.visible') + }) + }) + + context('when file is created outside of project path', function () { + beforeEach(function () { + this.newSpec = { + name: 'new_spec.js', + absolute: '/user/desktop/my_folder/new_spec.js', + } + + this.ipc.showNewSpecDialog.resolves({ + specs: this.specs, + path: this.newSpec.absolute, + }) + }) + + it('displays a dismissable warning message', function () { + cy.contains('New Spec File').click() + + cy.contains('Your file has been successfully created') + .should('be.visible') + .closest('.notification-wrap') + .find('.notification-close') + .click() + + cy.contains('Your file has been successfully created').should('not.be.visible') + }) + }) + }) + + context('Windows paths', function () { + beforeEach(function () { + this.ipc.getSpecs.yields(null, this.specsWindows) + }) + + context('when file is created within project path', function () { + beforeEach(function () { + this.newSpec = { + name: 'new_spec.js', + absolute: 'C:\\Users\\user\\project\\cypress\\integration\\new_spec.js', + relative: 'cypress\\integration\\new_spec.js', + } + + this.ipc.showNewSpecDialog.resolves({ + specs: { ...this.specsWindows, integration: this.specs.integration.concat(this.newSpec) }, + path: this.newSpec.absolute, + }) + }) + + it('adds and highlights new spec item', function () { + cy.contains('New Spec File').click() + cy.contains('new_spec.js').closest('.file').should('have.class', 'new-spec') + }) + + it('scrolls the new spec item into view', function () { + cy.contains('New Spec File').click() + cy.contains('new_spec.js').closest('.file').then(function ($el) { + cy.stub($el[0], 'scrollIntoView') + cy.contains('New Spec File').click() + cy.wrap($el[0].scrollIntoView).should('be.called') + }) + }) + + it('does not display warning message', function () { + cy.contains('New Spec File').click() + cy.contains('Your file has been successfully created').should('not.be.visible') + }) + }) + + context('when file is created outside of project path', function () { + beforeEach(function () { + this.newSpec = { + name: 'new_spec.js', + absolute: 'C:\\Users\\user\\Desktop\\my_folder\\new_spec.js', + } + + this.ipc.showNewSpecDialog.resolves({ + specs: this.specsWindows, + path: this.newSpec.absolute, + }) + }) + + it('displays a dismissable warning message', function () { + cy.contains('New Spec File').click() + + cy.contains('Your file has been successfully created') + .should('be.visible') + .closest('.notification-wrap') + .find('.notification-close') + .click() + + cy.contains('Your file has been successfully created').should('not.be.visible') + }) + }) + }) + }) }) diff --git a/packages/desktop-gui/src/lib/ipc.js b/packages/desktop-gui/src/lib/ipc.js index f9c63cc87a64..904f6eb428aa 100644 --- a/packages/desktop-gui/src/lib/ipc.js +++ b/packages/desktop-gui/src/lib/ipc.js @@ -66,6 +66,7 @@ register('request:access') register('setup:dashboard:project') register('set:project:id') register('show:directory:dialog') +register('show:new:spec:dialog') register('updater:check', false) register('updater:run', false) register('window:open') diff --git a/packages/desktop-gui/src/notifications/notification.scss b/packages/desktop-gui/src/notifications/notification.scss index 2ccf127293a3..5810c7a18805 100644 --- a/packages/desktop-gui/src/notifications/notification.scss +++ b/packages/desktop-gui/src/notifications/notification.scss @@ -1,5 +1,6 @@ .notification { bottom: 4.8rem; + padding-left: 1.2rem; padding-right: 1.2rem; position: fixed; right: 0; @@ -97,3 +98,9 @@ right: 1rem; z-index: 9999; } + +.new-spec-warning { + .content i { + color: #F5A327; + } +} diff --git a/packages/desktop-gui/src/specs/specs-list.jsx b/packages/desktop-gui/src/specs/specs-list.jsx index d4673ff9adef..804c2b531013 100644 --- a/packages/desktop-gui/src/specs/specs-list.jsx +++ b/packages/desktop-gui/src/specs/specs-list.jsx @@ -8,6 +8,7 @@ import Loader from 'react-loader' import Tooltip from '@cypress/react-tooltip' import FileOpener from './file-opener' +import Notification from '../notifications/notification' import ipc from '../lib/ipc' import projectsApi from '../projects/projects-api' import specsStore, { allIntegrationSpecsSpec, allComponentSpecsSpec } from './specs-store' @@ -62,6 +63,7 @@ class SpecsList extends Component { } this.filterRef = React.createRef() + this.newSpecRef = React.createRef() // when the specs are running and the user changes the search filter // we still want to show the previous button label to reflect what // is currently running @@ -79,6 +81,22 @@ class SpecsList extends Component { } } + componentDidUpdate () { + if (this.newSpecRef.current) { + this.newSpecRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // unset new spec after animation to prevent further scrolling + this.removeNewSpecTimeout = setTimeout(() => specsStore.setNewSpecPath(null), 3000) + } + } + + componentWillUnmount () { + if (this.removeNewSpecTimeout) { + clearTimeout(this.removeNewSpecTimeout) + } + + specsStore.setNewSpecPath(null) + } + render () { if (specsStore.isLoading) return @@ -132,8 +150,12 @@ class SpecsList extends Component { +
+ +
{this._specsList()} + {this._newSpecNotification()} ) } @@ -267,6 +289,18 @@ class SpecsList extends Component { specsStore.toggleExpandSpecFolder(specFolderPath) } + _createNewFile (e) { + e.preventDefault() + e.stopPropagation() + + ipc.showNewSpecDialog().then(({ specs, path }) => { + if (path) { + specsStore.setNewSpecPath(path) + specsStore.setSpecs(specs) + } + }) + } + _folderContent (spec, nestingLevel) { const isExpanded = spec.isExpanded const specType = spec.specType || 'integration' @@ -349,10 +383,11 @@ class SpecsList extends Component { } const isActive = specsStore.isChosen(spec) - const className = cs(`file level-${nestingLevel}`, { active: isActive }) + const isNew = specsStore.isNew(spec) + const className = cs(`file level-${nestingLevel}`, { active: isActive, 'new-spec': isNew }) return ( -
  • +
  • @@ -395,6 +430,16 @@ class SpecsList extends Component { ) } + _newSpecNotification () { + return ( + + + Your file has been successfully created. + However, since it was created outside of your integration folder or is not recognized as a spec file, it won't be visible in this list. + + ) + } + _openIntegrationFolder () { ipc.openFinder(this.props.project.integrationFolder) } diff --git a/packages/desktop-gui/src/specs/specs-store.js b/packages/desktop-gui/src/specs/specs-store.js index 0d7f412fa19a..99004796c1ae 100644 --- a/packages/desktop-gui/src/specs/specs-store.js +++ b/packages/desktop-gui/src/specs/specs-store.js @@ -61,6 +61,8 @@ export class SpecsStore { @observable isLoading = false @observable filter @observable selectedSpec + @observable newSpecAbsolutePath + @observable showNewSpecWarning = false @computed get specs () { return this._tree(this._files) @@ -77,6 +79,10 @@ export class SpecsStore { }) })) + if (this.newSpecAbsolutePath && !_.find(this._files, this.isNew)) { + this.showNewSpecWarning = true + } + this.isLoading = false } @@ -104,6 +110,15 @@ export class SpecsStore { } } + @action setNewSpecPath (absolutePath) { + this.newSpecAbsolutePath = absolutePath + this.dismissNewSpecWarning() + } + + @action dismissNewSpecWarning = () => { + this.showNewSpecWarning = false + } + @action setExpandSpecFolder (spec, isExpanded) { spec.setExpanded(isExpanded) } @@ -144,6 +159,10 @@ export class SpecsStore { return pathsEqual(this.chosenSpecPath, formRelativePath(spec)) } + isNew = (spec) => { + return pathsEqual(this.newSpecAbsolutePath, spec.absolute) + } + getSpecsFilterId ({ id, path = '' }) { const shortenedPath = path.replace(/.*cypress/, 'cypress') diff --git a/packages/desktop-gui/src/specs/specs.scss b/packages/desktop-gui/src/specs/specs.scss index 3b3d6c6829f7..2bd6c2da205b 100644 --- a/packages/desktop-gui/src/specs/specs.scss +++ b/packages/desktop-gui/src/specs/specs.scss @@ -19,6 +19,7 @@ $max-nesting-level: 14; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; + align-items: center; } .search { @@ -72,6 +73,15 @@ $max-nesting-level: 14; } } + .new-file-button { + padding-right: 15px; + + button { + font-size: 13px; + padding: 6px 10px; + } + } + .all-tests { margin-left: auto; font-size: 13px; @@ -197,6 +207,21 @@ $max-nesting-level: 14; } } + &.new-spec { + animation: 3s ease-in-out 1 new-spec-highlight; + + @keyframes new-spec-highlight { + 0%, 100% { + background-color: inherit; + color: inherit; + } + + 40%, 60% { + background-color: #cdedff; + } + } + } + &:hover, &:focus { background-color: #f8f8f8; cursor: pointer; diff --git a/packages/server/__snapshots__/spec_writer_spec.ts.js b/packages/server/__snapshots__/spec_writer_spec.ts.js index 543a61089330..d8975d33106a 100644 --- a/packages/server/__snapshots__/spec_writer_spec.ts.js +++ b/packages/server/__snapshots__/spec_writer_spec.ts.js @@ -430,3 +430,13 @@ it('test added to empty file', function() { }); ` + +exports['lib/util/spec_writer #createFile creates a new file with templated comments 1'] = ` +// my_new_spec.js created with Cypress +// +// Start writing your Cypress tests below! +// If you're unfamiliar with how Cypress works, +// check out the link below and learn how to write your first test: +// https://on.cypress.io/writing-first-test + +` diff --git a/packages/server/lib/gui/dialog.js b/packages/server/lib/gui/dialog.js deleted file mode 100644 index dc123baaffd5..000000000000 --- a/packages/server/lib/gui/dialog.js +++ /dev/null @@ -1,26 +0,0 @@ -const _ = require('lodash') -const { dialog } = require('electron') - -module.exports = { - show () { - // associate this dialog to the mainWindow - // so the user never loses track of which - // window the dialog belongs to. in other words - // if they blur off, they only need to focus back - // on the Cypress app for this dialog to appear again - // https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Sheets/Concepts/AboutSheets.html - - const props = { - // we only want the user to select a single - // directory. not multiple, and not files - properties: ['openDirectory'], - } - - return dialog.showOpenDialog(props) - .then((obj) => { - // return the first path since there can only ever - // be a single directory selection - return _.get(obj, ['filePaths', 0]) - }) - }, -} diff --git a/packages/server/lib/gui/dialog.ts b/packages/server/lib/gui/dialog.ts new file mode 100644 index 000000000000..71f8853b150a --- /dev/null +++ b/packages/server/lib/gui/dialog.ts @@ -0,0 +1,52 @@ +import _ from 'lodash' +import { dialog, OpenDialogOptions, SaveDialogOptions } from 'electron' +import path from 'path' + +import { get as getWindow } from './windows' + +export const show = () => { + // associate this dialog to the mainWindow + // so the user never loses track of which + // window the dialog belongs to. in other words + // if they blur off, they only need to focus back + // on the Cypress app for this dialog to appear again + // https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Sheets/Concepts/AboutSheets.html + + const props: OpenDialogOptions = { + // we only want the user to select a single + // directory. not multiple, and not files + properties: ['openDirectory'], + } + + return dialog.showOpenDialog(props) + .then((obj) => { + // return the first path since there can only ever + // be a single directory selection + return _.get(obj, ['filePaths', 0]) + }) +} + +export const showSaveDialog = (integrationFolder: string) => { + // attach to the desktop-gui window so it displays as a modal rather than a standalone window + const window = getWindow('INDEX') + const props: SaveDialogOptions = { + defaultPath: path.join(integrationFolder, 'untitled.spec.js'), + buttonLabel: 'Create File', + showsTagField: false, + filters: [{ + name: 'JavaScript', + extensions: ['.js'], + }, { + name: 'TypeScript', + extensions: ['.ts'], + }, { + name: 'Other', + extensions: ['*'], + }], + properties: ['createDirectory', 'showOverwriteConfirmation'], + } + + return dialog.showSaveDialog(window, props).then((obj) => { + return obj.filePath || null + }) +} diff --git a/packages/server/lib/gui/events.js b/packages/server/lib/gui/events.js index 7d0a088d3439..791fca79316b 100644 --- a/packages/server/lib/gui/events.js +++ b/packages/server/lib/gui/events.js @@ -11,6 +11,7 @@ const logs = require('./logs') const auth = require('./auth') const Windows = require('./windows') const { openExternal } = require('./links') +const files = require('./files') const open = require('../util/open') const user = require('../user') const errors = require('../errors') @@ -108,6 +109,11 @@ const handleEvent = function (options, bus, event, id, type, arg) { .then(send) .catch(sendErr) + case 'show:new:spec:dialog': + return files.showDialogAndCreateSpec() + .then(send) + .catch(sendErr) + case 'log:in': return user.logIn(arg) .then(send) diff --git a/packages/server/lib/gui/files.ts b/packages/server/lib/gui/files.ts new file mode 100644 index 000000000000..586b1fabc81d --- /dev/null +++ b/packages/server/lib/gui/files.ts @@ -0,0 +1,41 @@ +import openProject from '../open_project' +import { createFile } from '../util/spec_writer' +import { showSaveDialog } from './dialog' + +export const showDialogAndCreateSpec = () => { + return openProject.getConfig() + .then((cfg) => { + return showSaveDialog(cfg.integrationFolder).then((path) => { + return { + cfg, + path, + } + }) + }) + .tap(({ path }) => { + // only create file if they selected a file + if (path) { + return createFile(path) + } + + return + }) + .then(({ cfg, path }) => { + if (!path) { + return { + specs: null, + path, + } + } + + // reload specs now that we've added a new file + // we reload here so we can update ui immediately instead of + // waiting for file watching to send updated spec list + return openProject.getSpecs(cfg).then((specs) => { + return { + specs, + path, + } + }) + }) +} diff --git a/packages/server/lib/open_project.js b/packages/server/lib/open_project.js index 65c75d9402b5..a3ce54475678 100644 --- a/packages/server/lib/open_project.js +++ b/packages/server/lib/open_project.js @@ -152,6 +152,41 @@ const moduleFactory = () => { }) }, + getSpecs (cfg) { + return specsUtil.find(cfg) + .then((specs = []) => { + // TODO merge logic with "run.js" + if (debug.enabled) { + const names = _.map(specs, 'name') + + debug( + 'found %s using spec pattern \'%s\': %o', + pluralize('spec', names.length, true), + cfg.testFiles, + names, + ) + } + + const experimentalComponentTestingEnabled = _.get(cfg, 'resolved.experimentalComponentTesting.value', false) + + if (experimentalComponentTestingEnabled) { + // separate specs into integration and component lists + // note: _.remove modifies the array in place and returns removed elements + const component = _.remove(specs, { specType: 'component' }) + + return { + integration: specs, + component, + } + } + + // assumes all specs are integration specs + return { + integration: specs, + } + }) + }, + getSpecChanges (options = {}) { let currentSpecs = null @@ -225,38 +260,7 @@ const moduleFactory = () => { .then((cfg) => { createSpecsWatcher(cfg) - return specsUtil.find(cfg) - .then((specs = []) => { - // TODO merge logic with "run.js" - if (debug.enabled) { - const names = _.map(specs, 'name') - - debug( - 'found %s using spec pattern \'%s\': %o', - pluralize('spec', names.length, true), - cfg.testFiles, - names, - ) - } - - const experimentalComponentTestingEnabled = _.get(cfg, 'resolved.experimentalComponentTesting.value', false) - - if (experimentalComponentTestingEnabled) { - // separate specs into integration and component lists - // note: _.remove modifies the array in place and returns removed elements - const component = _.remove(specs, { specType: 'component' }) - - return { - integration: specs, - component, - } - } - - // assumes all specs are integration specs - return { - integration: specs, - } - }) + return this.getSpecs(cfg) }) } diff --git a/packages/server/lib/util/spec_writer.ts b/packages/server/lib/util/spec_writer.ts index dbf1fe7e04ce..941fb01cbb92 100644 --- a/packages/server/lib/util/spec_writer.ts +++ b/packages/server/lib/util/spec_writer.ts @@ -2,6 +2,17 @@ import { fs } from './fs' import { Visitor, builders as b, namedTypes as n, visit } from 'ast-types' import * as recast from 'recast' import { parse } from '@babel/parser' +import path from 'path' + +const newFileTemplate = (file) => { + return `// ${path.basename(file)} created with Cypress +// +// Start writing your Cypress tests below! +// If you're unfamiliar with how Cypress works, +// check out the link below and learn how to write your first test: +// https://on.cypress.io/writing-first-test +` +} export interface Command { selector?: string @@ -243,3 +254,7 @@ export const rewriteSpec = (path: string, astRules: Visitor<{}>) => { return fs.writeFile(path, code) }) } + +export const createFile = (path: string) => { + return fs.writeFile(path, newFileTemplate(path)) +} diff --git a/packages/server/test/unit/gui/dialog_spec.js b/packages/server/test/unit/gui/dialog_spec.js deleted file mode 100644 index cb7d5ab0d85e..000000000000 --- a/packages/server/test/unit/gui/dialog_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -require('../../spec_helper') - -const electron = require('electron') -const dialog = require(`${root}../lib/gui/dialog`) - -describe('gui/dialog', () => { - context('.show', () => { - beforeEach(function () { - this.showOpenDialog = (electron.dialog.showOpenDialog = sinon.stub().resolves({ - filePaths: [], - })) - }) - - it('calls dialog.showOpenDialog with args', function () { - dialog.show() - - expect(this.showOpenDialog).to.be.calledWith({ - properties: ['openDirectory'], - }) - }) - - it('resolves with first path', function () { - this.showOpenDialog.resolves({ - filePaths: ['foo', 'bar'], - }) - - return dialog.show().then((ret) => { - expect(ret).to.eq('foo') - }) - }) - - it('handles null paths', function () { - this.showOpenDialog.resolves({ - filePaths: null, - }) - - return dialog.show().then((ret) => { - expect(ret).to.eq(undefined) - }) - }) - - it('handles null obj', function () { - this.showOpenDialog.resolves(null) - - return dialog.show().then((ret) => { - expect(ret).to.eq(undefined) - }) - }) - }) -}) diff --git a/packages/server/test/unit/gui/dialog_spec.ts b/packages/server/test/unit/gui/dialog_spec.ts new file mode 100644 index 000000000000..f7fb4a58a998 --- /dev/null +++ b/packages/server/test/unit/gui/dialog_spec.ts @@ -0,0 +1,102 @@ +import '../../spec_helper' + +import { expect } from 'chai' +import 'sinon-chai' + +import { dialog } from 'electron' +import * as path from 'path' + +import { show, showSaveDialog } from '../../../lib/gui/dialog' +import * as windows from '../../../lib/gui/windows' + +describe('gui/dialog', () => { + context('.show', () => { + beforeEach(function () { + this.showOpenDialog = (dialog.showOpenDialog = sinon.stub().resolves({ + filePaths: [], + })) + }) + + it('calls dialog.showOpenDialog with args', function () { + show() + + expect(this.showOpenDialog).to.be.calledWith({ + properties: ['openDirectory'], + }) + }) + + it('resolves with first path', function () { + this.showOpenDialog.resolves({ + filePaths: ['foo', 'bar'], + }) + + return show().then((ret) => { + expect(ret).to.eq('foo') + }) + }) + + it('handles null paths', function () { + this.showOpenDialog.resolves({ + filePaths: null, + }) + + return show().then((ret) => { + expect(ret).to.eq(undefined) + }) + }) + + it('handles null obj', function () { + this.showOpenDialog.resolves(null) + + return show().then((ret) => { + expect(ret).to.eq(undefined) + }) + }) + }) + + context('.showSaveDialog', () => { + beforeEach(function () { + this.electronShowSaveDialog = (dialog.showSaveDialog = sinon.stub().resolves({ + canceled: true, + filePath: '', + })) + + this.integrationFolder = '/path/to/project/cypress/integration' + + sinon.stub(windows, 'get').returns({}) + }) + + it('attaches dialog to current window', function () { + showSaveDialog(this.integrationFolder) + + expect(windows.get).to.be.called + }) + + it('sets default path to untitled.spec.js in integration folder', function () { + showSaveDialog(this.integrationFolder) + + expect(this.electronShowSaveDialog).to.be.calledWithMatch({}, { + defaultPath: path.join(this.integrationFolder, 'untitled.spec.js'), + }) + }) + + it('resolves null when canceled', function () { + return showSaveDialog(this.integrationFolder).then((ret) => { + expect(ret).to.be.null + }) + }) + + it('resolves with chosen file path', function () { + const filePath = path.join(this.integrationFolder, 'my_new_spec.js') + + this.electronShowSaveDialog.resolves({ + canceled: false, + filePath, + }) + + return showSaveDialog(this.integrationFolder).then((ret) => { + expect(ret).to.equal(filePath) + }) + }) + }) +}) diff --git a/packages/server/test/unit/gui/events_spec.js b/packages/server/test/unit/gui/events_spec.js index 40e8f781af91..61ae3a1d8c7b 100644 --- a/packages/server/test/unit/gui/events_spec.js +++ b/packages/server/test/unit/gui/events_spec.js @@ -18,8 +18,9 @@ const openProject = require(`${root}../lib/open_project`) const open = require(`${root}../lib/util/open`) const auth = require(`${root}../lib/gui/auth`) const logs = require(`${root}../lib/gui/logs`) -const events = require(`../../../lib/gui/events`) +const events = require(`${root}../lib/gui/events`) const dialog = require(`${root}../lib/gui/dialog`) +const files = require(`${root}../lib/gui/files`) const ensureUrl = require(`${root}../lib/util/ensure-url`) const konfig = require(`${root}../lib/konfig`) const api = require(`${root}../lib/api`) @@ -120,6 +121,39 @@ describe('lib/gui/events', () => { }) }) }) + + describe('show:new:spec:dialog', () => { + it('calls files.showDialogAndCreateSpec and returns', function () { + const response = { + path: '/path/to/project/cypress/integration/my_new_spec.js', + specs: { + integration: [ + { + name: 'app_spec.js', + absolute: '/path/to/project/cypress/integration/app_spec.js', + relative: 'cypress/integration/app_spec.js', + }, + ], + }, + } + + sinon.stub(files, 'showDialogAndCreateSpec').resolves(response) + + return this.handleEvent('show:new:spec:dialog').then((assert) => { + return assert.sendCalledWith(response) + }) + }) + + it('catches errors', function () { + const err = new Error('foo') + + sinon.stub(files, 'showDialogAndCreateSpec').rejects(err) + + return this.handleEvent('show:new:spec:dialog').then((assert) => { + return assert.sendErrCalledWith(err) + }) + }) + }) }) context('user', () => { diff --git a/packages/server/test/unit/gui/files_spec.ts b/packages/server/test/unit/gui/files_spec.ts new file mode 100644 index 000000000000..bfc1fd36f2d4 --- /dev/null +++ b/packages/server/test/unit/gui/files_spec.ts @@ -0,0 +1,86 @@ +import '../../spec_helper' + +import { expect } from 'chai' +import 'sinon-chai' + +import { showDialogAndCreateSpec } from '../../../lib/gui/files' +//@ts-ignore +import openProject from '../../../lib/open_project' +import { ProjectE2E } from '../../../lib/project-e2e' +import * as dialog from '../../../lib/gui/dialog' +import * as specWriter from '../../../lib/util/spec_writer' + +describe('gui/files', () => { + context('.showDialogAndCreateSpec', () => { + beforeEach(function () { + this.integrationFolder = '/path/to/project/cypress/integration' + this.selectedPath = `${this.integrationFolder}/my_new_spec.js` + + this.config = { + integrationFolder: this.integrationFolder, + some: 'config', + } + + this.specs = { + integration: [ + { + name: 'app_spec.js', + absolute: '/path/to/project/cypress/integration/app_spec.js', + relative: 'cypress/integration/app_spec.js', + }, + ], + } + + this.err = new Error('foo') + + sinon.stub(ProjectE2E.prototype, 'open').resolves() + sinon.stub(ProjectE2E.prototype, 'getConfig').resolves(this.config) + + this.showSaveDialog = sinon.stub(dialog, 'showSaveDialog').resolves(this.selectedPath) + this.createFile = sinon.stub(specWriter, 'createFile').resolves({}) + this.getSpecs = sinon.stub(openProject, 'getSpecs').resolves(this.specs) + + return openProject.create('/_test-output/path/to/project-e2e') + }) + + it('calls dialog.showSaveDialog with integration folder from config', function () { + return showDialogAndCreateSpec().then(() => { + expect(this.showSaveDialog).to.be.calledWith(this.integrationFolder) + }) + }) + + it('calls specWriter.createFile with path selected from dialog', function () { + return showDialogAndCreateSpec().then(() => { + expect(this.createFile).to.be.calledWith(this.selectedPath) + }) + }) + + it('does not call specWriter.createFile when no file is selected', function () { + this.showSaveDialog.resolves(null) + + return showDialogAndCreateSpec().then(() => { + expect(this.createFile).not.to.be.called + }) + }) + + it('calls openProject.getSpecs with config and resolves specs and new file path', function () { + return showDialogAndCreateSpec().then((response) => { + expect(this.getSpecs).to.be.called + + expect(response.specs).to.equal(this.specs) + expect(response.path).to.equal(this.selectedPath) + }) + }) + + it('does not call openProject.getSpecs when no file is selected and sends nulls', function () { + this.showSaveDialog.resolves(null) + + return showDialogAndCreateSpec().then((response) => { + expect(this.getSpecs).not.to.be.called + + expect(response.specs).to.be.null + expect(response.path).to.be.null + }) + }) + }) +}) diff --git a/packages/server/test/unit/util/spec_writer_spec.ts b/packages/server/test/unit/util/spec_writer_spec.ts index bbf6a27543a7..30a26abc9fc5 100644 --- a/packages/server/test/unit/util/spec_writer_spec.ts +++ b/packages/server/test/unit/util/spec_writer_spec.ts @@ -6,7 +6,15 @@ import snapshot from 'snap-shot-it' import Fixtures from '../../support/helpers/fixtures' import { fs } from '../../../lib/util/fs' -import { generateCypressCommand, addCommandsToBody, generateTest, appendCommandsToTest, createNewTestInSuite, createNewTestInFile } from '../../../lib/util/spec_writer' +import { + generateCypressCommand, + addCommandsToBody, + generateTest, + appendCommandsToTest, + createNewTestInSuite, + createNewTestInFile, + createFile, +} from '../../../lib/util/spec_writer' const mockSpec = Fixtures.get('projects/studio/cypress/integration/unwritten.spec.js') const emptyCommentsSpec = Fixtures.get('projects/studio/cypress/integration/empty-comments.spec.js') @@ -185,4 +193,10 @@ describe('lib/util/spec_writer', () => { createNewTestInFile({ absoluteFile: '' }, exampleTestCommands, 'test added to empty file') }) }) + + describe('#createFile', () => { + it('creates a new file with templated comments', () => { + createFile('/path/to/project/cypress/integration/my_new_spec.js') + }) + }) }) diff --git a/yarn.lock b/yarn.lock index 2133a9830927..43cfbffb5fbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3027,7 +3027,7 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^26.3.0", "@jest/types@^26.6.2": +"@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== @@ -7422,7 +7422,7 @@ ansi-regex@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= -ansi-regex@^4.1.0: +ansi-regex@^4.0.0, ansi-regex@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== @@ -28735,7 +28735,7 @@ react-inspector@5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@16.13.1, react-is@^16.12.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6: +react-is@16.13.1, react-is@^16.12.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==