From 9028054874a55f5201596dc7b1841dd76170cf83 Mon Sep 17 00:00:00 2001 From: Alexander Wolf Date: Tue, 26 Mar 2019 21:09:02 +0100 Subject: [PATCH] Test CreateNewProjectWizard components (#361) * WIP: Added tests * WIP: ProjectType undefined not throwing in test * fixed flow * added tests * reverted to children render props * Merge branch 'master' into test-create-new-project-wiz * Fixed linting & replaced snapshot with simple smoke tests * addressed review comments --- .eslintrc.js | 8 + .../CreateNewProjectWizard/BuildPane.js | 2 +- .../CreateNewProjectWizard.js | 52 +-- .../CreateNewProjectWizard/ImportExisting.js | 2 +- .../CreateNewProjectWizard/ProjectName.js | 6 +- .../CreateNewProjectWizard/ProjectPath.js | 42 ++- .../CreateNewProjectWizard/SubmitButton.js | 2 +- .../__tests__/BuildPane.test.js | 92 +++++ .../__tests__/BuildStepProgress.test.js | 61 +++ .../__tests__/CreateNewProjectWizard.test.js | 235 ++++++++++++ .../__tests__/ImportExisting.test.js | 17 + .../__tests__/MainPane.test.js | 131 +++++++ .../__tests__/ProjectName.test.js | 117 ++++++ .../__tests__/ProjectPath.test.js | 88 +++++ .../__tests__/SubmitButton.test.js | 57 +++ .../__tests__/SummaryPane.test.js | 33 ++ .../__snapshots__/BuildPane.test.js.snap | 357 ++++++++++++++++++ .../BuildStepProgress.test.js.snap | 69 ++++ .../CreateNewProjectWizard.test.js.snap | 48 +++ .../__snapshots__/ImportExisting.test.js.snap | 65 ++++ .../__snapshots__/MainPane.test.js.snap | 112 ++++++ .../__snapshots__/ProjectPath.test.js.snap | 101 +++++ .../__snapshots__/SummaryPane.test.js.snap | 176 +++++++++ .../{ => __tests__}/helpers.test.js | 38 +- yarn.lock | 1 + 25 files changed, 1847 insertions(+), 65 deletions(-) create mode 100644 src/components/CreateNewProjectWizard/__tests__/BuildPane.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/BuildStepProgress.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/CreateNewProjectWizard.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/ImportExisting.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/MainPane.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/ProjectName.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/ProjectPath.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/SubmitButton.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/SummaryPane.test.js create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildPane.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildStepProgress.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/CreateNewProjectWizard.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/ImportExisting.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/MainPane.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/ProjectPath.test.js.snap create mode 100644 src/components/CreateNewProjectWizard/__tests__/__snapshots__/SummaryPane.test.js.snap rename src/components/CreateNewProjectWizard/{ => __tests__}/helpers.test.js (93%) diff --git a/.eslintrc.js b/.eslintrc.js index 59821f67..cd96350c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,4 +17,12 @@ module.exports = { 'flowtype/generic-spacing': 0, 'jest/no-large-snapshots': ['warn', { maxSize: 100 }], }, + overrides: [ + { + files: ['*.test.js', '*.spec.js'], + rules: { + 'flowtype/require-valid-file-annotation': 0, + }, + }, + ], }; diff --git a/src/components/CreateNewProjectWizard/BuildPane.js b/src/components/CreateNewProjectWizard/BuildPane.js index 59793dda..5b86e703 100644 --- a/src/components/CreateNewProjectWizard/BuildPane.js +++ b/src/components/CreateNewProjectWizard/BuildPane.js @@ -31,7 +31,7 @@ const BUILD_STEPS = { }, }; -const BUILD_STEP_KEYS: Array = Object.keys(BUILD_STEPS); +export const BUILD_STEP_KEYS: Array = Object.keys(BUILD_STEPS); type Props = { projectName: string, diff --git a/src/components/CreateNewProjectWizard/CreateNewProjectWizard.js b/src/components/CreateNewProjectWizard/CreateNewProjectWizard.js index d1f62a89..46b86fcf 100644 --- a/src/components/CreateNewProjectWizard/CreateNewProjectWizard.js +++ b/src/components/CreateNewProjectWizard/CreateNewProjectWizard.js @@ -29,12 +29,37 @@ import type { Field, Status, Step } from './types'; import type { ProjectType, ProjectInternal, AppSettings } from '../../types'; import type { Dispatch } from '../../actions/types'; -const FORM_STEPS: Array = [ +export const FORM_STEPS: Array = [ 'projectName', 'projectType', 'projectIcon', 'projectStarter', ]; + +export const dialogOptionsFolderExists = { + type: 'warning', + title: 'Project directory exists', + message: + "Looks like there's already a project with that name. Did you mean to import it instead?", + buttons: ['OK'], +}; + +export const dialogCallbackFolderExists = ( + resolve: (result: any) => void, + reject: (error: any) => void +) => (result: number) => { + if (result === 0) { + return reject(); + } + + resolve(); +}; + +export const dialogStarterNotFoundErrorArgs = (projectStarter: string) => [ + `Starter ${projectStarter} not found`, + 'Please check your starter url or use the starter selection to pick a starter.', +]; + const { dialog } = remote; type Props = { @@ -75,7 +100,7 @@ const initialState = { settings: null, }; -class CreateNewProjectWizard extends PureComponent { +export class CreateNewProjectWizard extends PureComponent { state = initialState; timeoutId: number; @@ -129,25 +154,13 @@ class CreateNewProjectWizard extends PureComponent { }; checkProjectLocationUsage = () => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const projectName = getProjectNameSlug(this.state.projectName); if (checkIfProjectExists(this.props.projectHomePath, projectName)) { // show warning that the project folder already exists & stop creation dialog.showMessageBox( - { - type: 'warning', - title: 'Project directory exists', - message: - "Looks like there's already a project with that name. Did you mean to import it instead?", - buttons: ['OK'], - }, - result => { - if (result === 0) { - return reject(); - } - - resolve(); - } + dialogOptionsFolderExists, + dialogCallbackFolderExists(resolve, reject) ); } else { resolve(); @@ -170,10 +183,7 @@ class CreateNewProjectWizard extends PureComponent { if (!exists) { // starter not found - dialog.showErrorBox( - `Starter ${projectStarter} not found`, - 'Please check your starter url or use the starter selection to pick a starter.' - ); + dialog.showErrorBox(...dialogStarterNotFoundErrorArgs(projectStarter)); throw new Error('starter-not-found'); } }; diff --git a/src/components/CreateNewProjectWizard/ImportExisting.js b/src/components/CreateNewProjectWizard/ImportExisting.js index 14f9a94d..a77a7136 100644 --- a/src/components/CreateNewProjectWizard/ImportExisting.js +++ b/src/components/CreateNewProjectWizard/ImportExisting.js @@ -13,7 +13,7 @@ type Props = { isOnboarding: boolean, }; -const ImportExisting = ({ isOnboarding }: Props) => { +export const ImportExisting = ({ isOnboarding }: Props) => { if (isOnboarding) { // When the user is onboarding, there's a much more prominent prompt to // import existing projects, so we don't need this extra snippet. diff --git a/src/components/CreateNewProjectWizard/ProjectName.js b/src/components/CreateNewProjectWizard/ProjectName.js index 00c67f3a..78fb0d2f 100644 --- a/src/components/CreateNewProjectWizard/ProjectName.js +++ b/src/components/CreateNewProjectWizard/ProjectName.js @@ -157,6 +157,8 @@ class ProjectName extends PureComponent { } }; + setRef = (node: HTMLElement) => (this.node = node); + render() { const { name, @@ -176,7 +178,7 @@ class ProjectName extends PureComponent { spacing={15} > (this.node = node)} + innerRef={this.setRef} type="text" value={randomizedOverrideName || name} isFocused={isFocused} @@ -219,7 +221,7 @@ class ProjectName extends PureComponent { } } -const ErrorMessage = styled.div` +export const ErrorMessage = styled.div` margin-top: 6px; color: ${COLORS.pink[700]}; `; diff --git a/src/components/CreateNewProjectWizard/ProjectPath.js b/src/components/CreateNewProjectWizard/ProjectPath.js index 7f96e70d..67569f06 100644 --- a/src/components/CreateNewProjectWizard/ProjectPath.js +++ b/src/components/CreateNewProjectWizard/ProjectPath.js @@ -21,25 +21,28 @@ type Props = { changeDefaultProjectPath: Dispatch, }; -class ProjectPath extends PureComponent { +export const CLAMP_AT = 29; + +export const dialogOptions = { + message: 'Select the directory of Project', + properties: ['openDirectory'], +}; + +export function dialogCallback(paths: ?[string]) { + // The user might cancel out without selecting a directory. + // In that case, do nothing. + if (!paths) { + return; + } + + // Only a single path should be selected + const [firstPath] = paths; + this.props.changeDefaultProjectPath(firstPath); +} + +export class ProjectPath extends PureComponent { updatePath = () => { - remote.dialog.showOpenDialog( - { - message: 'Select the directory of Project', - properties: ['openDirectory'], - }, - (paths: ?[string]) => { - // The user might cancel out without selecting a directory. - // In that case, do nothing. - if (!paths) { - return; - } - - // Only a single path should be selected - const [firstPath] = paths; - this.props.changeDefaultProjectPath(firstPath); - } - ); + remote.dialog.showOpenDialog(dialogOptions, dialogCallback.bind(this)); }; render() { @@ -55,7 +58,6 @@ class ProjectPath extends PureComponent { // Using CSS text-overflow is proving challenging, so we'll just crop it // with JS. - const CLAMP_AT = 29; let displayedProjectPath = fullProjectPath; if (displayedProjectPath.length > CLAMP_AT) { displayedProjectPath = `${displayedProjectPath.slice(0, CLAMP_AT - 1)}…`; @@ -81,7 +83,7 @@ const MainText = styled.div` color: ${COLORS.gray[400]}; `; -const DirectoryButton = styled(TextButton)` +export const DirectoryButton = styled(TextButton)` font-family: 'Fira Mono'; font-size: 12px; color: ${COLORS.gray[600]}; diff --git a/src/components/CreateNewProjectWizard/SubmitButton.js b/src/components/CreateNewProjectWizard/SubmitButton.js index 28d9d09b..39300aba 100644 --- a/src/components/CreateNewProjectWizard/SubmitButton.js +++ b/src/components/CreateNewProjectWizard/SubmitButton.js @@ -72,7 +72,7 @@ const SubmitButtonIconWrapper = styled.div` margin: auto; `; -const ChildWrapper = styled.div` +export const ChildWrapper = styled.div` line-height: 48px; `; diff --git a/src/components/CreateNewProjectWizard/__tests__/BuildPane.test.js b/src/components/CreateNewProjectWizard/__tests__/BuildPane.test.js new file mode 100644 index 00000000..55e6b205 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/BuildPane.test.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import BuildPane, { BUILD_STEP_KEYS } from '../BuildPane'; +import createProject from '../../../services/create-project.service'; + +jest.mock('../../../services/create-project.service'); + +describe('BuildPane component', () => { + let wrapper; + let instance; + const newProject = { + projectName: 'New project', + projectType: 'create-react-app', + projectIcon: 'icon', + projectStarter: '', + }; + + const mockHandleCompleteBuild = jest.fn(); + + jest.useFakeTimers(); + + const shallowRenderBuildPane = project => + shallow( + + ); + + const testOutputs = ['Installing packages', 'Dependencies installed']; + + beforeEach(() => { + wrapper = shallowRenderBuildPane(newProject); + instance = wrapper.instance(); + + // Mock console errors so they don't output while running the test + jest.spyOn(console, 'error'); + console.error.mockImplementation(() => {}); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + BUILD_STEP_KEYS.forEach(buildStep => { + it(`should render build step ${buildStep}`, () => { + wrapper.setProps({ + currentBuildStep: buildStep, + }); + expect(wrapper).toMatchSnapshot(); + }); + }); + + it('should call createProject', () => { + wrapper.setProps({ + status: 'building-project', + }); + jest.runAllTimers(); + expect(createProject).toHaveBeenCalled(); + }); + + it('should throw error if a project prop is missing', () => { + wrapper = shallowRenderBuildPane(); + instance = wrapper.instance(); + expect(instance.buildProject).toThrow(/insufficient data/i); + }); + + it('should handle status updated', () => { + // Note: Instance.handleStatusUpdate is called from createProject service + // with data from stdout we're testing this here with a mock array of output strings + testOutputs.forEach(output => { + instance.handleStatusUpdate(output); + expect(instance.state.currentBuildStep).toMatchSnapshot(); + }); + }); + + it('should call handleComplete', () => { + // Note: HandleComplete is also called from createProject service + // so it's OK to call this here directly as it's triggered + // once the service finished the work. + instance.handleComplete(newProject); + jest.runAllTimers(); + expect(mockHandleCompleteBuild).toHaveBeenCalledWith(newProject); + }); + + it('should handle error message', () => { + instance.handleError('npx: installed'); + expect(instance.state.currentBuildStep).toEqual(BUILD_STEP_KEYS[1]); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/BuildStepProgress.test.js b/src/components/CreateNewProjectWizard/__tests__/BuildStepProgress.test.js new file mode 100644 index 00000000..605775cf --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/BuildStepProgress.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import BuildStepProgress from '../BuildStepProgress'; + +describe('BuildStepProgress component', () => { + let wrapper; + let instance; + + const mockStep = { + copy: 'Building...', + additionalCopy: 'This may take some time', + }; + + jest.useFakeTimers(); + + beforeEach(() => { + wrapper = shallow(); + instance = wrapper.instance(); + }); + + it('should render upcoming', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('should render in-progress', () => { + wrapper.setProps({ status: 'in-progress' }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render done', () => { + wrapper.setProps({ status: 'done' }); + expect(wrapper).toMatchSnapshot(); + }); + + describe('Component logic', () => { + it('should hide additional copy if done', () => { + wrapper.setState({ + shouldShowAdditionalCopy: true, + }); + wrapper.setProps({ + status: 'done', + }); + expect(instance.state.shouldShowAdditionalCopy).toBeFalsy(); + }); + + it('should show additonal copy after a delay', () => { + wrapper.setProps({ + status: 'in-progress', + }); + jest.runAllTimers(); + expect(instance.state.shouldShowAdditionalCopy).toBeTruthy(); + }); + + it('should clear timeout on unmount', () => { + window.clearTimeout = jest.fn(); + instance.componentWillUnmount(); + expect(window.clearTimeout).toHaveBeenCalledWith(instance.timeoutId); + }); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/CreateNewProjectWizard.test.js b/src/components/CreateNewProjectWizard/__tests__/CreateNewProjectWizard.test.js new file mode 100644 index 00000000..7df6eea7 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/CreateNewProjectWizard.test.js @@ -0,0 +1,235 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { remote } from 'electron'; // Mocked + +import { + CreateNewProjectWizard, + dialogOptionsFolderExists, + dialogCallbackFolderExists, + dialogStarterNotFoundErrorArgs, + FORM_STEPS, +} from '../CreateNewProjectWizard'; +import { initialState as appSettingsInitialState } from '../../../reducers/app-settings.reducer'; + +const projectHomePath = '/users/guppy-dev'; + +jest.mock('../../../services/create-project.service', () => ({ + checkIfProjectExists: jest.fn( + (path, name) => path === '/users/guppy-dev' && name === 'first-project' + ), + getProjectNameSlug: jest.requireActual( + '../../../services/create-project.service' + ).getProjectNameSlug, +})); + +jest.mock('../../../services/check-if-url-exists.service', () => ({ + urlExists: jest.fn(starterUrl => + Promise.resolve(starterUrl.includes('gatsby-blog-starter')) + ), +})); + +jest.useFakeTimers(); + +const { dialog } = remote; + +describe('CreateNewProjectWizard component', () => { + let wrapper; + let instance; + let mockActions; + + // Create some projects with just a name key as it is needed for uniqueness check + const mockProjects = { + 'first-project-id': { + name: 'first-project', + }, + 'second-project-id': { + name: 'second-project', + }, + }; + + const newProject = { + name: 'New project', + projectType: 'create-react-app', + projectIcon: 'icon', + }; + + const mockAppSettingsState = { + ...appSettingsInitialState, + general: { + defaultProjectPath: projectHomePath, + }, + }; + + beforeEach(() => { + mockActions = { + addProject: jest.fn(), + createNewProjectCancel: jest.fn(), + createNewProjectFinish: jest.fn(), + }; + window.clearTimeout = jest.fn(); + wrapper = shallow( + + ); + instance = wrapper.instance(); + }); + it('should render TwoPaneModal', () => { + expect(wrapper.renderProp('children')(true)).toMatchSnapshot(); + }); + + it('should initialize on mount', () => { + instance.reinitialize = jest.fn(); + instance.componentDidMount(); + expect(instance.reinitialize).toHaveBeenCalled(); + }); + + it('should clear timeout on unmount', () => { + instance.componentWillUnmount(); + expect(window.clearTimeout).toHaveBeenCalledWith(instance.timeoutId); + }); + + it('should update field and check uniqueness of projectName', () => { + const newProjectName = 'New Project Name'; + instance.verifyProjectNameUniqueness = jest.fn(); + instance.updateFieldValue('projectName', newProjectName); + expect(instance.state.projectName).toEqual(newProjectName); + expect(instance.state.activeField).toEqual('projectName'); + expect(instance.verifyProjectNameUniqueness).toHaveBeenCalledWith( + newProjectName + ); + + // update other field + instance.verifyProjectNameUniqueness = jest.fn(); + instance.updateFieldValue('projectIcon', 'new-icon'); + expect(instance.verifyProjectNameUniqueness).not.toHaveBeenCalled(); + }); + + it('should set focus', () => { + instance.focusField('projectName'); + expect(instance.state.activeField).toEqual('projectName'); + }); + + it('should update isProjectTaken', () => { + instance.updateFieldValue('projectName', 'First project'); + expect(instance.state.isProjectNameTaken).toBe(true); + instance.updateFieldValue('projectName', 'New project'); + expect(instance.state.isProjectNameTaken).toBe(false); + }); + + it('should check project location usage (exists on disk)', () => { + wrapper.setState({ + projectName: 'first-project', + }); + instance.checkProjectLocationUsage(); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + dialogOptionsFolderExists, + expect.anything() + ); + + const resolveMock = jest.fn(); + const rejectMock = jest.fn(); + dialogCallbackFolderExists(resolveMock, rejectMock).call(instance, 0); + expect(rejectMock).toHaveBeenCalled(); + + dialogCallbackFolderExists(resolveMock, rejectMock).call(instance, 1); + expect(resolveMock).toHaveBeenCalled(); + }); + + it('should check project location usage (new project)', async () => { + wrapper.setState({ + projectName: 'new-project', + }); + await expect(instance.checkProjectLocationUsage()).resolves.toBeUndefined(); + }); + + it('should check if starter url exists', async () => { + wrapper.setState({ + projectStarter: 'gatsby-blog-starter', + }); + + expect(instance.checkIfStarterUrlExists()).resolves.not.toThrow(); + + // should return undefined for empty starter + wrapper.setState({ + projectStarter: '', + }); + + expect(await instance.checkIfStarterUrlExists()).toBeUndefined(); + + wrapper.setState({ + projectStarter: 'not-existing-starter', + }); + + await expect(instance.checkIfStarterUrlExists()).rejects.toThrow( + 'starter-not-found' + ); + + expect(dialog.showErrorBox).toHaveBeenCalledWith( + ...dialogStarterNotFoundErrorArgs( + 'https://github.com/gatsbyjs/not-existing-starter' + ) + ); + }); + + it('should dispatch createNewProjectFinish & add project (finishBuilding)', () => { + const projectType = 'create-react-app'; + wrapper.setState({ + projectType, + }); + instance.reinitialize = jest.fn(); + + instance.finishBuilding(newProject); + expect(mockActions.createNewProjectFinish).toHaveBeenCalled(); + + jest.runAllTimers(); + expect(mockActions.addProject).toHaveBeenCalledWith( + newProject, + projectHomePath, + projectType, + true + ); + expect(instance.reinitialize).toHaveBeenCalled(); + }); + + it('should throw error if projectType not defined (finishBuilding)', () => { + expect(instance.finishBuilding).toThrow( + 'Project created without projectType' + ); + }); + + describe('Submit button (next step & build)', () => { + it('should set next step if not build click', () => { + wrapper.setState({ + currentStep: 'projectName', + projectType: 'gatsby', + }); + instance.handleSubmit(); + expect(instance.state).toEqual( + expect.objectContaining({ + currentStep: FORM_STEPS[1], + activeField: FORM_STEPS[1], + }) + ); + }); + + it('should trigger build & checks before starting build', async () => { + // mock checks + instance.checkProjectLocationUsage = jest.fn(); + instance.checkIfStarterUrlExists = jest.fn(); + wrapper.setState({ + currentStep: 'projectStarter', + projectType: 'gatsby', + }); + await instance.handleSubmit(); + expect(instance.checkProjectLocationUsage).toHaveBeenCalled(); + expect(instance.checkIfStarterUrlExists).toHaveBeenCalled(); + expect(instance.state.status).toEqual('building-project'); + }); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/ImportExisting.test.js b/src/components/CreateNewProjectWizard/__tests__/ImportExisting.test.js new file mode 100644 index 00000000..ca20bd9c --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/ImportExisting.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ImportExisting } from '../ImportExisting'; + +describe('ImportExisting component', () => { + let wrapper; + + it('should render (after onboarding)', () => { + wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it(`shouldn't render (during onboarding)`, () => { + wrapper = shallow(); + expect(wrapper.html()).toBeNull(); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/MainPane.test.js b/src/components/CreateNewProjectWizard/__tests__/MainPane.test.js new file mode 100644 index 00000000..7cb1164c --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/MainPane.test.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Spring } from 'react-spring'; +import lolex from 'lolex'; + +import MainPane from '../MainPane'; +import ProjectName from '../ProjectName'; +import ProjectPath from '../ProjectPath'; +import { FORM_STEPS } from '../CreateNewProjectWizard'; + +describe('MainPane component', () => { + let wrapper; + let instance; + let mockActions; + + const project = { + projectName: 'Project name', + projectType: 'gatsby', + projectIcon: 'an-icon', + projectStarter: null, + }; + + const shallowRenderMainPane = ( + step, + isProjectNameTaken = false, + status = 'filling-in-form' + ) => { + mockActions = { + updateFieldValue: jest.fn(), + focusField: jest.fn(), + handleSubmit: jest.fn(), + }; + return shallow( + + ); + }; + + lolex.install(); + + describe('Rendering', () => { + const checkConditionalSteps = i => { + wrapper = shallowRenderMainPane(i); + instance = wrapper.instance(); + expect(instance.renderConditionalSteps(i)).toMatchSnapshot(); + }; + + it('should render ProjectName & ProjectPath', () => { + wrapper = shallowRenderMainPane(0) + .find(Spring) + .renderProp('children')({ offset: 50 }); + expect(wrapper.find(ProjectName).exists()).toBe(true); + expect(wrapper.find(ProjectPath).exists()).toBe(true); + }); + + for (let i = 0; i < FORM_STEPS.length; i++) { + it(`should render step ${FORM_STEPS[i]}`, () => checkConditionalSteps(i)); + } + }); + describe('Component logic', () => { + beforeEach(() => { + wrapper = shallowRenderMainPane(0); + instance = wrapper.instance(); + }); + + it('should trigger projectName focus', () => { + instance.handleFocusProjectName(); + expect(mockActions.focusField).toHaveBeenCalledWith('projectName'); + }); + + it('should trigger projectName blur', () => { + instance.handleBlurProjectName(); + expect(mockActions.focusField).toHaveBeenCalledWith(null); + }); + + it('should trigger projectStarter focus', () => { + instance.handleFocusStarter(); + expect(mockActions.focusField).toHaveBeenCalledWith('projectStarter'); + }); + + it('should update projectName', () => { + instance.updateProjectName('New name'); + expect(mockActions.updateFieldValue).toHaveBeenCalledWith( + 'projectName', + 'New name' + ); + }); + + it('should update projectType', () => { + instance.updateProjectType('create-react-app'); + expect(mockActions.updateFieldValue).toHaveBeenCalledWith( + 'projectType', + 'create-react-app' + ); + }); + + it('should update projectIcon', () => { + instance.updateProjectIcon('new-icon'); + expect(mockActions.updateFieldValue).toHaveBeenCalledWith( + 'projectIcon', + 'new-icon' + ); + }); + + it('should update projectStarter', () => { + instance.updateGatsbyStarter('gatsby-blog-starter'); + expect(mockActions.updateFieldValue).toHaveBeenCalledWith( + 'projectStarter', + 'gatsby-blog-starter' + ); + }); + + it('should disable submit if form not ready', () => { + // Clear props + wrapper.setProps({ + projectType: null, + projectIcon: null, + }); + + let disabled = instance.isSubmitDisabled(3, 3); + expect(disabled).toBeTruthy(); + }); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/ProjectName.test.js b/src/components/CreateNewProjectWizard/__tests__/ProjectName.test.js new file mode 100644 index 00000000..04e10104 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/ProjectName.test.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import ProjectName, { ErrorMessage } from '../ProjectName'; +import lolex from 'lolex'; + +jest.mock('react-tippy', () => ({ + Tooltip: jest.fn(({ children }) =>
{children}
), +})); + +describe('ProjectName component', () => { + let wrapper; + let instance; + let mockActions; + + const mountProjectName = isTaken => { + mockActions = { + handleFocus: jest.fn(), + handleBlur: jest.fn(), + handleChange: jest.fn(), + handleSubmit: jest.fn(), + }; + return mount( + + ); + }; + + lolex.install(); + + describe('Rendering', () => { + it('should render input', () => { + wrapper = mountProjectName(false); + expect(wrapper.exists()).toBe(true); + }); + it('should render error message "projectName already taken"', () => { + wrapper = mountProjectName(true); + expect(wrapper.find(ErrorMessage).exists()).toBe(true); + }); + }); + + describe('Component logic', () => { + let mockFocus; + let mockBlur; + beforeEach(() => { + jest.useFakeTimers(); + window.clearTimeout = jest.fn(); + // Mounting needed here so refs are working otherwise this.node will be undefined + wrapper = mountProjectName(); + instance = wrapper.instance(); + mockFocus = jest.fn(); + mockBlur = jest.fn(); + + instance.setRef({ + focus: mockFocus, + blur: mockBlur, + }); + }); + + it('should focus input & show tooltip after a delay (after mount)', () => { + instance.componentDidMount(); + expect(mockFocus).toHaveBeenCalled(); + + // Check tooltip + expect(instance.state.showRandomizeTooltip).toBeFalsy(); + jest.runAllTimers(); + expect(instance.state.showRandomizeTooltip).toBeTruthy(); + }); + + it('should clearTimout on unmount', () => { + instance.componentWillUnmount(); + expect(window.clearTimeout).toHaveBeenCalledWith(instance.timeoutId); + }); + + it('should remove tooltip', () => { + jest.runAllTimers(); + expect(instance.state.showRandomizeTooltip).toBeTruthy(); + instance.getRidOfRandomizeTooltip(); + expect(window.clearTimeout).toHaveBeenCalledWith(instance.timeoutId); + expect(instance.state.showRandomizeTooltip).toBeFalsy(); + }); + + it('should create a random name', () => { + instance.handleRandomize(); + + expect(mockActions.handleChange).toHaveBeenCalledWith(expect.anything()); + expect(mockFocus).toHaveBeenCalledTimes(1); + }); + + // Todo: Check what is submitted with handleSubmit? + it('should handle submit on enter key', () => { + instance.maybeHandleSubmit({ + key: 'Enter', + }); + expect(mockActions.handleSubmit).toHaveBeenCalled(); + expect(mockBlur).toHaveBeenCalled(); + }); + + it('should not submit on any other key', () => { + instance.maybeHandleSubmit({ + key: 'a', + }); + expect(mockActions.handleSubmit).not.toHaveBeenCalled(); + expect(mockBlur).not.toHaveBeenCalled(); + }); + + // Note: Scramble method is not tested yet - would be nice to have. + // I don't fully understand the method & randomizeCount handling. + // randomizeCount starts with zero and will be set to new name length + // after first run - so it will scramble from this to a new name + // --> why is the if-else there? + // it('should scramble string', () => {}); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/ProjectPath.test.js b/src/components/CreateNewProjectWizard/__tests__/ProjectPath.test.js new file mode 100644 index 00000000..482e8743 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/ProjectPath.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import path from 'path'; +import { remote } from 'electron'; // Mocked + +import { + ProjectPath, + DirectoryButton, + CLAMP_AT, + dialogOptions, + dialogCallback, +} from '../ProjectPath'; +import { getProjectNameSlug } from '../../../services/create-project.service'; + +jest.mock('path', () => ({ + join: (...args) => args.join('/').concat('/'), + resolve: () => '/', +})); + +const { dialog } = remote; + +describe('ProjectPath component', () => { + let wrapper; + let mockChangeDefaultProjectPath; + const projectHome = 'homedir/user/guppy-dev'; // 22 chars + const shallowRender = name => { + mockChangeDefaultProjectPath = jest.fn(); + return shallow( + + ); + }; + const shortName = 'Demo'; + const longName = 'Magical Summer Fox'; + + describe('Rendering', () => { + it('should render path with-out clamping', () => { + wrapper = shallowRender(shortName); + expect(wrapper).toMatchSnapshot(); + expect( + wrapper + .find(DirectoryButton) + .children() + .text() + ).toEqual(path.join(projectHome, getProjectNameSlug(shortName))); + }); + + it('should render clamped', () => { + wrapper = shallowRender(longName); + expect(wrapper).toMatchSnapshot(); + + expect( + wrapper + .find(DirectoryButton) + .children() + .text() + ).toHaveLength(CLAMP_AT); + }); + }); + + describe('Component logic', () => { + beforeEach(() => { + wrapper = shallowRender(shortName); + }); + + it('should show dialog', () => { + wrapper.find(DirectoryButton).simulate('click'); + + expect(dialog.showOpenDialog).toHaveBeenCalledWith( + dialogOptions, + expect.anything() + ); + }); + + it('should change default project path if picked a path', () => { + dialogCallback.call(wrapper.instance(), ['/some/path']); + expect(mockChangeDefaultProjectPath).toHaveBeenCalledWith('/some/path'); + }); + + it(`shouldn't change default project path if no path selected`, () => { + dialogCallback.call(wrapper.instance(), null); + expect(mockChangeDefaultProjectPath).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/SubmitButton.test.js b/src/components/CreateNewProjectWizard/__tests__/SubmitButton.test.js new file mode 100644 index 00000000..92bb3ef0 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/SubmitButton.test.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SubmitButton, { ChildWrapper } from '../SubmitButton'; + +describe('SubmitButton component', () => { + let wrapper; + let mockSubmit; + + const shallowRender = (hasBeenSubmitted, ready, isDisabled = false) => + shallow( + + ); + beforeEach(() => { + mockSubmit = jest.fn(); + }); + + it(`should render 'Building...' button text`, () => { + wrapper = shallowRender(true, true); + expect( + wrapper + .find(ChildWrapper) + .children() + .text() + ).toEqual('Building...'); + }); + + it(`should render 'Lets do this' button text`, () => { + wrapper = shallowRender(false, true); + expect( + wrapper + .find(ChildWrapper) + .children() + .text() + ).toEqual(`Let's do this`); + }); + + it(`should render 'Next' button text`, () => { + wrapper = shallowRender(false, false); + expect( + wrapper + .find(ChildWrapper) + .children() + .text() + ).toEqual(`Next`); + }); + + it('should submit', () => { + wrapper = shallowRender(true, true); + wrapper.simulate('click'); + expect(mockSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/SummaryPane.test.js b/src/components/CreateNewProjectWizard/__tests__/SummaryPane.test.js new file mode 100644 index 00000000..41f13370 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/SummaryPane.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import SummaryPane from '../SummaryPane'; +import projectTypes from '../../../config/project-types'; + +describe('SummaryPane component', () => { + let wrapper; + const steps = ['projectName', 'projectType', 'projectIcon', 'projectStarter']; + + steps.forEach(step => + it(`should render summary for ${step}`, () => { + if (step === 'projectType') { + wrapper = shallow(); + Object.keys(projectTypes).forEach(projectType => { + wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + } else { + wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + } + }) + ); + + it('should throw an error if step not found', () => { + expect(() => + shallow() + ).toThrow(); + }); +}); diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildPane.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildPane.test.js.snap new file mode 100644 index 00000000..902073a8 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildPane.test.js.snap @@ -0,0 +1,357 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BuildPane component should handle status updated 1`] = `"installingDependencies"`; + +exports[`BuildPane component should handle status updated 2`] = `"guppification"`; + +exports[`BuildPane component should render build step creatingProjectDirectory 1`] = ` + + + + + + Project Created! + + + + Building Project... + + + + + + + + + + + +`; + +exports[`BuildPane component should render build step guppification 1`] = ` + + + + + + Project Created! + + + + Building Project... + + + + + + + + + + + +`; + +exports[`BuildPane component should render build step installingCliTool 1`] = ` + + + + + + Project Created! + + + + Building Project... + + + + + + + + + + + +`; + +exports[`BuildPane component should render build step installingDependencies 1`] = ` + + + + + + Project Created! + + + + Building Project... + + + + + + + + + + + +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildStepProgress.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildStepProgress.test.js.snap new file mode 100644 index 00000000..520bac72 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/BuildStepProgress.test.js.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BuildStepProgress component should render done 1`] = ` + + + + + + + Building... + + + +`; + +exports[`BuildStepProgress component should render in-progress 1`] = ` + + + + + + + Building... + + + +`; + +exports[`BuildStepProgress component should render upcoming 1`] = ` + + + + + Building... + + + +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/CreateNewProjectWizard.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/CreateNewProjectWizard.test.js.snap new file mode 100644 index 00000000..b3686a50 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/CreateNewProjectWizard.test.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateNewProjectWizard component should render TwoPaneModal 1`] = ` + + + } + isDismissable={true} + isFolded={false} + leftPane={ + + + + } + onDismiss={[MockFunction]} + rightPane={ + + } + transitionState={true} + /> + +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ImportExisting.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ImportExisting.test.js.snap new file mode 100644 index 00000000..963d9909 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ImportExisting.test.js.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportExisting component should render (after onboarding) 1`] = ` + + + + + + + + Already have a project you'd like to manage with Guppy? + + + Import it instead + + . + + +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/MainPane.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/MainPane.test.js.snap new file mode 100644 index 00000000..5b885f18 --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/MainPane.test.js.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MainPane component Rendering should render step projectIcon 1`] = ` +Object { + "lastIndex": 3, + "steps": Array [ + + + + + , + + + + + , + ], +} +`; + +exports[`MainPane component Rendering should render step projectName 1`] = ` +Object { + "lastIndex": 3, + "steps": Array [], +} +`; + +exports[`MainPane component Rendering should render step projectStarter 1`] = ` +Object { + "lastIndex": 3, + "steps": Array [ + + + + + , + + + + + , + + + + + , + ], +} +`; + +exports[`MainPane component Rendering should render step projectType 1`] = ` +Object { + "lastIndex": 3, + "steps": Array [ + + + + + , + ], +} +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ProjectPath.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ProjectPath.test.js.snap new file mode 100644 index 00000000..5fad311a --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/ProjectPath.test.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProjectPath component Rendering should render clamped 1`] = ` + + Project will be created in + + + + homedir/user/guppy-dev/magic… + + + +`; + +exports[`ProjectPath component Rendering should render path with-out clamping 1`] = ` + + Project will be created in + + + + homedir/user/guppy-dev/demo/ + + + +`; diff --git a/src/components/CreateNewProjectWizard/__tests__/__snapshots__/SummaryPane.test.js.snap b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/SummaryPane.test.js.snap new file mode 100644 index 00000000..5626e5bf --- /dev/null +++ b/src/components/CreateNewProjectWizard/__tests__/__snapshots__/SummaryPane.test.js.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SummaryPane component should render summary for projectIcon 1`] = ` + + + + Project Icon + + + Choose an icon, to help you recognize this project from a list. + + + +`; + +exports[`SummaryPane component should render summary for projectName 1`] = ` + + + + + + + Create new project + + + Let's start by giving your new project a name. + + + + + + +`; + +exports[`SummaryPane component should render summary for projectStarter 1`] = ` + + + + Gatsby Starter + + + Please enter a starter for your project (e.g. gatsby-starter-blog or repo. url) or pick one from the starters list. + + + This step is optional. Just leave the field empty to use the default Gatsby starter. But picking a starter will help to bootstrap your project e.g. you can easily create your own blog by picking one of the blog starter templates. + + + For a better overview you can also have a look at the + + + Gatsby starters library + + + + +`; + +exports[`SummaryPane component should render summary for projectType 1`] = ` + + + + Project Type + + + + Vanilla React + + + + Vanilla React projects use create-react-app, an official command-line tool built by Facebook for bootstrapping React applications. + + + It's a fantastic general-purpose tool, and is the recommended approach if you're looking to become a skilled React developer. + + + + + Learn more about create-react-app. + + + + + +`; + +exports[`SummaryPane component should render summary for projectType 2`] = ` + + + + Project Type + + + + Gatsby + + + + Gatsby is a blazing fast static site generator for React. + + + It's great for building blogs and personal websites, and provides amazing performance out-of-the-box. A great choice for quickly getting products built. + + + + + Learn more about Gatsby. + + + + + +`; + +exports[`SummaryPane component should render summary for projectType 3`] = ` + + + + Project Type + + + + Next.js + + + + Next.js is a lightweight framework for static and server-rendered applications. + + + Server-rendered by default. No need to worry about routing. A great choice for quickly getting products built with server-side rendering by a Node.js server. + + + + + Learn more about Next.js. + + + + + +`; diff --git a/src/components/CreateNewProjectWizard/helpers.test.js b/src/components/CreateNewProjectWizard/__tests__/helpers.test.js similarity index 93% rename from src/components/CreateNewProjectWizard/helpers.test.js rename to src/components/CreateNewProjectWizard/__tests__/helpers.test.js index b3256b3f..1d30c9a7 100644 --- a/src/components/CreateNewProjectWizard/helpers.test.js +++ b/src/components/CreateNewProjectWizard/__tests__/helpers.test.js @@ -1,19 +1,19 @@ -/* eslint-disable flowtype/require-valid-file-annotation */ -import { - replaceProjectStarterStringWithUrl, - defaultStarterUrl, -} from './helpers'; - -describe('Build helpers', () => { - describe('Gatsby helper', () => { - it('should replace Gatsby starter string with url', () => { - expect(replaceProjectStarterStringWithUrl('gatsby-starter-blog')).toEqual( - defaultStarterUrl + 'gatsby-starter-blog' - ); - }); - - it('should ignore empty starter strings', () => { - expect(replaceProjectStarterStringWithUrl('')).toEqual(''); - }); - }); -}); +/* eslint-disable flowtype/require-valid-file-annotation */ +import { + replaceProjectStarterStringWithUrl, + defaultStarterUrl, +} from '../helpers'; + +describe('Build helpers', () => { + describe('Gatsby helper', () => { + it('should replace Gatsby starter string with url', () => { + expect(replaceProjectStarterStringWithUrl('gatsby-starter-blog')).toEqual( + defaultStarterUrl + 'gatsby-starter-blog' + ); + }); + + it('should ignore empty starter strings', () => { + expect(replaceProjectStarterStringWithUrl('')).toEqual(''); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b6da5455..157ed609 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8970,6 +8970,7 @@ object-keys@^1.0.11, object-keys@~1.0.0: object-keys@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.12.tgz#09c53855377575310cca62f55bb334abff7b3ed2" + integrity sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag== object-keys@~0.4.0: version "0.4.0"