diff --git a/.eslintrc b/.eslintrc index b720df006e..7da33e7bc0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,6 +34,7 @@ "jasmine": true, "expect": true, "spyOn": true, - "waitsFor": true + "waitsFor": true, + "sinon": true, } } diff --git a/lib/controllers/commit-view-controller.js b/lib/controllers/commit-view-controller.js new file mode 100644 index 0000000000..3265c4772b --- /dev/null +++ b/lib/controllers/commit-view-controller.js @@ -0,0 +1,89 @@ +/** @babel */ +/** @jsx etch.dom */ +/* eslint react/no-unknown-property: "off" */ + +import etch from 'etch'; + +import CommitView from '../views/commit-view'; +import ModelStateRegistry from '../models/model-state-registry'; + +export default class CommitViewController { + constructor(props) { + this.props = props; + + this.commit = this.commit.bind(this); + this.handleMessageChange = this.handleMessageChange.bind(this); + this.repoStateRegistry = new ModelStateRegistry(CommitViewController, { + initialModel: props.repository, + save: () => { + return { + regularCommitMessage: this.regularCommitMessage, + amendingCommitMessage: this.amendingCommitMessage, + }; + }, + restore: (state = {}) => { + this.regularCommitMessage = state.regularCommitMessage || ''; + this.amendingCommitMessage = state.amendingCommitMessage || ''; + }, + }); + + etch.initialize(this); + } + + update(props) { + this.props = {...this.props, ...props}; + this.repoStateRegistry.setModel(this.props.repository); + return etch.update(this); + } + + render() { + let message = this.regularCommitMessage; + if (this.props.isAmending) { + message = this.amendingCommitMessage; + if (!message.length && this.props.lastCommit) { + message = this.props.lastCommit.message; + } + } else if (!message.length && this.props.mergeMessage) { + message = this.props.mergeMessage; + } + return ( + + ); + } + + async commit(message) { + await this.props.commit(message); + this.regularCommitMessage = ''; + this.amendingCommitMessage = ''; + etch.update(this); + } + + handleMessageChange(newMessage) { + if (this.props.isAmending) { + this.amendingCommitMessage = newMessage; + } else { + this.regularCommitMessage = newMessage; + } + etch.update(this); + } + + destroy() { + this.repoStateRegistry.save(); + return etch.destroy(this); + } +} diff --git a/lib/controllers/git-controller.js b/lib/controllers/git-controller.js index 9052ffa791..4fe344dfda 100644 --- a/lib/controllers/git-controller.js +++ b/lib/controllers/git-controller.js @@ -14,12 +14,12 @@ import FilePatchController from './file-patch-controller'; import GitPanelController from './git-panel-controller'; import StatusBarTileController from './status-bar-tile-controller'; import ModelObserver from '../models/model-observer'; +import ModelStateRegistry from '../models/model-state-registry'; const nullFilePatchState = { filePath: null, filePatch: null, stagingStatus: null, - amending: null, }; export default class GitController extends React.Component { @@ -46,9 +46,19 @@ export default class GitController extends React.Component { super(props, context); this.state = { ...nullFilePatchState, + amending: false, gitPanelActive: !!props.savedState.gitPanelActive, }; + this.repositoryStateRegistry = new ModelStateRegistry(GitController, { + save: () => { + return {amending: this.state.amending}; + }, + restore: (state = {}) => { + this.setState({amending: !!state.amending}); + }, + }); + this.showFilePatchForPath = this.showFilePatchForPath.bind(this); this.showMergeConflictFileForPath = this.showMergeConflictFileForPath.bind(this); this.didChangeAmending = this.didChangeAmending.bind(this); @@ -74,8 +84,13 @@ export default class GitController extends React.Component { ); } + componentWillMount() { + this.repositoryStateRegistry.setModel(this.props.repository); + } + componentWillReceiveProps(newProps) { this.repositoryObserver.setActiveModel(newProps.repository); + this.repositoryStateRegistry.setModel(newProps.repository); } render() { @@ -116,6 +131,7 @@ export default class GitController extends React.Component { commandRegistry={this.props.commandRegistry} notificationManager={this.props.notificationManager} repository={this.props.repository} + isAmending={this.state.amending} didSelectFilePath={this.showFilePatchForPath} didSelectMergeConflictFile={this.showMergeConflictFileForPath} didChangeAmending={this.didChangeAmending} @@ -146,6 +162,7 @@ export default class GitController extends React.Component { } componentWillUnmount() { + this.repositoryStateRegistry.save(); this.subscriptions.dispose(); } @@ -162,7 +179,7 @@ export default class GitController extends React.Component { const filePatch = await repository.getFilePatchForPath(filePath, {staged: stagingStatus === 'staged', amending}); if (filePatch) { - this.setState({filePath, filePatch, stagingStatus, amending}, () => { + this.setState({filePath, filePatch, stagingStatus}, () => { // TODO: can be better done w/ a prop? if (activate && this.filePatchControllerPane) { this.filePatchControllerPane.activate(); @@ -188,6 +205,7 @@ export default class GitController extends React.Component { } didChangeAmending(isAmending) { + this.setState({amending: isAmending}); return this.showFilePatchForPath(this.state.filePath, this.state.stagingStatus, {amending: isAmending}); } diff --git a/lib/controllers/git-panel-controller.js b/lib/controllers/git-panel-controller.js index b86c54d09b..f7867cb3e1 100644 --- a/lib/controllers/git-panel-controller.js +++ b/lib/controllers/git-panel-controller.js @@ -18,8 +18,6 @@ export default class GitPanelController { this.setAmending = this.setAmending.bind(this); this.checkout = this.checkout.bind(this); this.abortMerge = this.abortMerge.bind(this); - - this.viewStateByRepository = new WeakMap(); this.repositoryObserver = new ModelObserver({ fetchData: this.fetchRepositoryData.bind(this), didUpdate: () => etch.update(this), @@ -28,26 +26,13 @@ export default class GitPanelController { etch.initialize(this); } - viewStateForRepository(repository) { - if (repository) { - if (!this.viewStateByRepository.has(repository)) { - this.viewStateByRepository.set(repository, {}); - } - return this.viewStateByRepository.get(repository); - } else { - return null; - } - } - render() { - const viewState = this.viewStateForRepository(this.getActiveRepository()); const modelData = this.repositoryObserver.getActiveModelData() || {fetchInProgress: true}; return ( this.props.onChangeMessage && this.props.onChangeMessage(this.editor.getText())), this.editor.onDidChangeCursorPosition(() => { etch.update(this); }), - this.editor.getBuffer().onDidChangeText(() => { etch.update(this); }), props.commandRegistry.add(this.element, {'github:commit': () => this.commit()}), ); - this.setMessageAndAmendStatus(); - this.updateStateForRepository(); const grammar = atom.grammars.grammarForScopeName(COMMIT_GRAMMAR_SCOPE); if (grammar) { @@ -38,8 +39,12 @@ export default class CommitView { } update(props) { + const previousMessage = this.props.message; this.props = {...this.props, ...props}; - this.setMessageAndAmendStatus(); + const newMessage = this.props.message; + if (this.editor && previousMessage !== newMessage && this.editor.getText() !== newMessage) { + this.editor.setText(newMessage); + } return etch.update(this); } @@ -52,33 +57,6 @@ export default class CommitView { this.grammarSubscription.dispose(); } - setMessageAndAmendStatus() { - const viewState = this.props.viewState || {}; - const message = viewState.message || ''; - if (this.props.message && message === '') { - this.editor.setText(this.props.message); - this.editor.setCursorBufferPosition([0, 0]); - } else { - this.editor.setText(message || ''); - if (viewState.cursorPosition) { this.editor.setCursorBufferPosition(viewState.cursorPosition); } - } - this.refs.amend.checked = Boolean(viewState.amendInProgress); - } - - updateStateForRepository() { - if (this.props.viewState) { - Object.assign(this.props.viewState, { - message: this.editor.getText(), - cursorPosition: this.editor.getCursorBufferPosition(), - amendInProgress: this.refs.amend.checked, - }); - } - } - - readAfterUpdate() { - this.updateStateForRepository(); - } - render() { let remainingCharsClassName = ''; if (this.getRemainingCharacters() < 0) { @@ -104,10 +82,16 @@ export default class CommitView { onclick={this.abortMerge} style={{display: this.props.isMerging ? '' : 'none'}}>Abort Merge
@@ -118,55 +102,18 @@ export default class CommitView { ); } - async abortMerge() { - const choice = atom.confirm({ - message: 'Abort merge', - detailedMessage: 'Are you sure?', - buttons: ['Abort', 'Cancel'], - }); - if (choice !== 0) { return null; } - - try { - await this.props.abortMerge(); - this.editor.setText(''); - } catch (e) { - if (e.code === 'EDIRTYSTAGED') { - this.props.notificationManager.addError(`Cannot abort because ${e.path} is both dirty and staged.`); - } - } - return etch.update(this); + abortMerge() { + this.props.abortMerge(); } - async handleAmendBoxClick() { - const checked = this.refs.amend.checked; - const viewState = this.props.viewState || {}; - viewState.amendInProgress = checked; - if (checked) { - viewState.messagePriorToAmending = this.editor.getText(); - const lastCommitMessage = this.props.lastCommit ? this.props.lastCommit.message : ''; - this.editor.setText(lastCommitMessage); - } else { - this.editor.setText(viewState.messagePriorToAmending || ''); - } - this.editor.setCursorBufferPosition([0, 0]); - if (this.props.setAmending) { await this.props.setAmending(checked); } - return etch.update(this); + handleAmendBoxClick() { + this.props.setAmending(this.refs.amend.checked); } - async commit() { + commit() { if (this.isCommitButtonEnabled()) { - try { - await this.props.commit(this.editor.getText(), {amend: this.refs.amend.checked}); - this.editor.setText(''); - this.editor.getBuffer().clearUndoStack(); - } catch (e) { - if (e.code === 'ECONFLICT') { - this.props.notificationManager.addError('Cannot commit without resolving all the merge conflicts first.'); - } - } + this.props.commit(this.editor.getText()); } - this.refs.amend.checked = false; - return etch.update(this); } getRemainingCharacters() { diff --git a/lib/views/git-panel-view.js b/lib/views/git-panel-view.js index e5d175894a..755aaa2b09 100644 --- a/lib/views/git-panel-view.js +++ b/lib/views/git-panel-view.js @@ -5,7 +5,7 @@ import etch from 'etch'; import StagingView from './staging-view'; -import CommitView from './commit-view'; +import CommitViewController from '../controllers/commit-view-controller'; export default class GitPanelView { constructor(props) { @@ -46,25 +46,21 @@ export default class GitPanelView { lastCommit={this.props.lastCommit} isAmending={this.props.isAmending} /> - 0} mergeConflictsExist={this.props.mergeConflicts.length > 0} commit={this.props.commit} + amending={this.props.amending} setAmending={this.props.setAmending} - disableCommitButton={this} abortMerge={this.props.abortMerge} branchName={this.props.branchName} commandRegistry={this.props.commandRegistry} - notificationManager={this.props.notificationManager} - maximumCharacterLimit={72} - message={this.props.mergeMessage} + mergeMessage={this.props.mergeMessage} isMerging={this.props.isMerging} isAmending={this.props.isAmending} lastCommit={this.props.lastCommit} repository={this.props.repository} - viewState={this.props.viewState} />
); diff --git a/test/controllers/commit-view-controller.test.js b/test/controllers/commit-view-controller.test.js new file mode 100644 index 0000000000..9ed68c2ded --- /dev/null +++ b/test/controllers/commit-view-controller.test.js @@ -0,0 +1,84 @@ +/** @babel */ + +import CommitViewController from '../../lib/controllers/commit-view-controller'; +import {cloneRepository, buildRepository} from '../helpers'; + +describe('CommitViewController', () => { + let atomEnvironment, commandRegistry; + + beforeEach(() => { + atomEnvironment = global.buildAtomEnvironment(); + commandRegistry = atomEnvironment.commands; + }); + + afterEach(() => { + atomEnvironment.destroy(); + }); + + it('correctly updates state when switching repos', async () => { + const workdirPath1 = await cloneRepository('three-files'); + const repository1 = await buildRepository(workdirPath1); + const workdirPath2 = await cloneRepository('three-files'); + const repository2 = await buildRepository(workdirPath2); + const controller = new CommitViewController({commandRegistry, repository: repository1}); + + assert.equal(controller.regularCommitMessage, ''); + assert.equal(controller.amendingCommitMessage, ''); + + controller.regularCommitMessage = 'regular message 1'; + controller.amendingCommitMessage = 'amending message 1'; + + await controller.update({repository: repository2}); + assert.equal(controller.regularCommitMessage, ''); + assert.equal(controller.amendingCommitMessage, ''); + + await controller.update({repository: repository1}); + assert.equal(controller.regularCommitMessage, 'regular message 1'); + assert.equal(controller.amendingCommitMessage, 'amending message 1'); + }); + + describe('the passed commit message', () => { + let controller, commitView, lastCommit; + beforeEach(async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + controller = new CommitViewController({commandRegistry, repository}); + commitView = controller.refs.commitView; + lastCommit = {sha: 'a1e23fd45', message: 'last commit message'}; + }); + + it('is set to the regularCommitMessage in the default case', async () => { + controller.regularCommitMessage = 'regular message'; + await controller.update(); + assert.equal(commitView.props.message, 'regular message'); + }); + + describe('when isAmending is true', () => { + it('is set to the last commits message if amendingCommitMessage is blank', async () => { + controller.amendingCommitMessage = 'amending commit message'; + await controller.update({isAmending: true, lastCommit}); + assert.equal(commitView.props.message, 'amending commit message'); + }); + + it('is set to amendingCommitMessage if it is set', async () => { + controller.amendingCommitMessage = 'amending commit message'; + await controller.update({isAmending: true, lastCommit}); + assert.equal(commitView.props.message, 'amending commit message'); + }); + }); + + describe('when a merge message is defined', () => { + it('is set to the merge message if regularCommitMessage is blank', async () => { + controller.regularCommitMessage = ''; + await controller.update({mergeMessage: 'merge conflict!'}); + assert.equal(commitView.props.message, 'merge conflict!'); + }); + + it('is set to regularCommitMessage if it is set', async () => { + controller.regularCommitMessage = 'regular commit message'; + await controller.update({mergeMessage: 'merge conflict!'}); + assert.equal(commitView.props.message, 'regular commit message'); + }); + }); + }); +}); diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js index c9ae689a1d..f062145159 100644 --- a/test/controllers/file-patch-controller.test.js +++ b/test/controllers/file-patch-controller.test.js @@ -2,7 +2,6 @@ import fs from 'fs'; import path from 'path'; -import sinon from 'sinon'; import {cloneRepository, buildRepository} from '../helpers'; import FilePatch from '../../lib/models/file-patch'; diff --git a/test/controllers/git-controller.test.js b/test/controllers/git-controller.test.js index 52e771dae9..3da688d5e5 100644 --- a/test/controllers/git-controller.test.js +++ b/test/controllers/git-controller.test.js @@ -4,7 +4,6 @@ import path from 'path'; import fs from 'fs'; import React from 'react'; -import sinon from 'sinon'; import {shallow} from 'enzyme'; import {cloneRepository, buildRepository} from '../helpers'; @@ -245,4 +244,23 @@ describe('GitController', () => { assert.equal(wrapper.instance().focusGitPanel.callCount, 1); }); }); + + it('correctly updates state when switching repos', async () => { + const workdirPath1 = await cloneRepository('three-files'); + const repository1 = await buildRepository(workdirPath1); + const workdirPath2 = await cloneRepository('three-files'); + const repository2 = await buildRepository(workdirPath2); + + app = React.cloneElement(app, {repository: repository1}); + const wrapper = shallow(app); + + assert.equal(wrapper.state('amending'), false); + + wrapper.setState({amending: true}); + wrapper.setProps({repository: repository2}); + assert.equal(wrapper.state('amending'), false); + + wrapper.setProps({repository: repository1}); + assert.equal(wrapper.state('amending'), true); + }); }); diff --git a/test/controllers/git-panel-controller.test.js b/test/controllers/git-panel-controller.test.js index c9ea8203bb..81c2eb063b 100644 --- a/test/controllers/git-panel-controller.test.js +++ b/test/controllers/git-panel-controller.test.js @@ -3,19 +3,21 @@ import fs from 'fs'; import path from 'path'; -import sinon from 'sinon'; import dedent from 'dedent-js'; -import {cloneRepository, buildRepository} from '../helpers'; import GitPanelController from '../../lib/controllers/git-panel-controller'; +import {cloneRepository, buildRepository} from '../helpers'; +import {AbortMergeError, CommitError} from '../../lib/models/repository'; + describe('GitPanelController', () => { - let atomEnvironment, workspace, commandRegistry; + let atomEnvironment, workspace, commandRegistry, notificationManager; beforeEach(() => { atomEnvironment = global.buildAtomEnvironment(); workspace = atomEnvironment.workspace; commandRegistry = atomEnvironment.commands; + notificationManager = atomEnvironment.notifications; }); afterEach(() => { @@ -40,7 +42,7 @@ describe('GitPanelController', () => { assert.isDefined(controller.refs.gitPanel.refs.repoInfo); }); - it('keeps the state of the GitPanelView in sync with the assigned repository', async done => { + it('keeps the state of the GitPanelView in sync with the assigned repository', async () => { const workdirPath1 = await cloneRepository('three-files'); const repository1 = await buildRepository(workdirPath1); const workdirPath2 = await cloneRepository('three-files'); @@ -76,33 +78,104 @@ describe('GitPanelController', () => { const didChangeAmending = sinon.spy(); const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const controller = new GitPanelController({workspace, commandRegistry, repository, didChangeAmending}); + const controller = new GitPanelController({workspace, commandRegistry, repository, didChangeAmending, isAmending: false}); await controller.getLastModelDataRefreshPromise(); assert.deepEqual(controller.refs.gitPanel.props.stagedChanges, []); assert.equal(didChangeAmending.callCount, 0); await controller.setAmending(true); assert.equal(didChangeAmending.callCount, 1); + await controller.update({isAmending: true}); assert.deepEqual( controller.refs.gitPanel.props.stagedChanges, await controller.getActiveRepository().getStagedChangesSinceParentCommit(), ); await controller.commit('Delete most of the code', {amend: true}); - await controller.getLastModelDataRefreshPromise(); - assert(!controller.refs.gitPanel.props.isAmending); + assert.equal(didChangeAmending.callCount, 2); }); + describe('abortMerge()', () => { + it('shows an error notification when abortMerge() throws an EDIRTYSTAGED exception', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + sinon.stub(repository, 'abortMerge', async () => { + await Promise.resolve(); + throw new AbortMergeError('EDIRTYSTAGED', 'a.txt'); + }); + + const controller = new GitPanelController({workspace, commandRegistry, notificationManager, repository}); + assert.equal(notificationManager.getNotifications().length, 0); + sinon.stub(atom, 'confirm').returns(0); + await controller.abortMerge(); + assert.equal(notificationManager.getNotifications().length, 1); + }); + + it('resets merge related state', async () => { + const workdirPath = await cloneRepository('merge-conflict'); + const repository = await buildRepository(workdirPath); + + await repository.git.merge('origin/branch') + .then(() => { throw new Error('Expected merge to throw an error'); }) + .catch(() => true); + + const controller = new GitPanelController({workspace, commandRegistry, repository}); + await controller.getLastModelDataRefreshPromise(); + let modelData = controller.repositoryObserver.getActiveModelData(); + + assert.notEqual(modelData.mergeConflicts.length, 0); + assert.isTrue(modelData.isMerging); + assert.isOk(modelData.mergeMessage); + + sinon.stub(atom, 'confirm').returns(0); + await controller.abortMerge(); + await controller.getLastModelDataRefreshPromise(); + modelData = controller.repositoryObserver.getActiveModelData(); + + assert.equal(modelData.mergeConflicts.length, 0); + assert.isFalse(modelData.isMerging); + assert.isNull(modelData.mergeMessage); + }); + }); + + describe('commit(message)', () => { + it('shows an error notification when committing throws an ECONFLICT exception', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + sinon.stub(repository, 'commit', async () => { + await Promise.resolve(); + throw new CommitError('ECONFLICT'); + }); + + const controller = new GitPanelController({workspace, commandRegistry, notificationManager, repository}); + assert.equal(notificationManager.getNotifications().length, 0); + await controller.commit(); + assert.equal(notificationManager.getNotifications().length, 1); + }); + + it('sets amending to false', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + sinon.stub(repository, 'commit', () => Promise.resolve()); + const didChangeAmending = sinon.stub(); + const controller = new GitPanelController({workspace, commandRegistry, repository, didChangeAmending}); + + await controller.commit('message'); + assert.equal(didChangeAmending.callCount, 1); + }); + }); + + describe('integration tests', () => { it('can stage and unstage files and commit', async () => { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath, 'b.txt')); - const controller = new GitPanelController({workspace, commandRegistry, repository}); + const controller = new GitPanelController({workspace, commandRegistry, repository, didChangeAmending: sinon.stub()}); await controller.getLastModelDataRefreshPromise(); const stagingView = controller.refs.gitPanel.refs.stagingView; - const commitView = controller.refs.gitPanel.refs.commitView; + const commitView = controller.refs.gitPanel.refs.commitViewController.refs.commitView; assert.equal(stagingView.props.unstagedChanges.length, 2); assert.equal(stagingView.props.stagedChanges.length, 0); diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index db1efce90b..b99b1da405 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -4,7 +4,6 @@ import fs from 'fs'; import path from 'path'; import etch from 'etch'; -import sinon from 'sinon'; import {cloneRepository, buildRepository, setUpLocalAndRemoteRepositories} from '../helpers'; import StatusBarTileController from '../../lib/controllers/status-bar-tile-controller'; diff --git a/test/github-package.test.js b/test/github-package.test.js index 034ff089e3..0b430fd622 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -5,7 +5,6 @@ import {Directory} from 'atom'; import fs from 'fs'; import path from 'path'; import temp from 'temp'; -import sinon from 'sinon'; import {cloneRepository} from './helpers'; import GithubPackage from '../lib/github-package'; diff --git a/test/helpers.js b/test/helpers.js index b4e0f91bb7..f845dd7e8e 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -7,6 +7,7 @@ import temp from 'temp'; import {Directory} from 'atom'; import React from 'react'; import ReactDom from 'react-dom'; +import sinon from 'sinon'; import Repository from '../lib/models/repository'; import GitShellOutStrategy from '../lib/git-shell-out-strategy'; @@ -195,8 +196,14 @@ export function createRenderer() { return renderer; } +// eslint-disable-next-line jasmine/no-global-setup +beforeEach(() => { + global.sinon = sinon.sandbox.create(); +}); + // eslint-disable-next-line jasmine/no-global-setup afterEach(() => { activeRenderers.forEach(r => r.unmount()); activeRenderers = []; + global.sinon.restore(); }); diff --git a/test/models/file-system-change-observer.test.js b/test/models/file-system-change-observer.test.js index 11fc83f004..33b3ac5223 100644 --- a/test/models/file-system-change-observer.test.js +++ b/test/models/file-system-change-observer.test.js @@ -2,7 +2,6 @@ import fs from 'fs'; import path from 'path'; -import sinon from 'sinon'; import {cloneRepository, buildRepository, setUpLocalAndRemoteRepositories} from '../helpers'; diff --git a/test/models/model-state-registry.test.js b/test/models/model-state-registry.test.js new file mode 100644 index 0000000000..0d83ca3f8d --- /dev/null +++ b/test/models/model-state-registry.test.js @@ -0,0 +1,138 @@ +/** @babel */ + +import ModelStateRegistry from '../../lib/models/model-state-registry'; + +const Type1 = {type: 1}; +const Type2 = {type: 2}; + +const model1 = {model: 1}; +const model2 = {model: 2}; + +describe('ModelStateRegistry', () => { + beforeEach(() => { + ModelStateRegistry.clearSavedState(); + }); + + describe('#setModel', () => { + it('saves the previous data to be restored later', () => { + let data; + const registry = new ModelStateRegistry(Type1, { + initialModel: model1, + save: () => data, + restore: (saved = {}) => { data = saved; }, + }); + assert.deepEqual(data, {}); + data = {some: 'data'}; + registry.setModel(model2); + assert.deepEqual(data, {}); + registry.setModel(model1); + assert.deepEqual(data, {some: 'data'}); + }); + + it('does not call save or restore if the model has not changed', () => { + let data; + const save = sinon.spy(() => data); + const restore = sinon.spy((saved = {}) => { data = saved; }); + const registry = new ModelStateRegistry(Type1, { + initialModel: model1, + save, + restore, + }); + save.reset(); + restore.reset(); + registry.setModel(model1); + assert.equal(save.callCount, 0); + assert.equal(restore.callCount, 0); + }); + + it('does not call save or restore for a model that does not exist', () => { + const save = sinon.stub(); + const restore = sinon.stub(); + const registry = new ModelStateRegistry(Type1, { + initialModel: model1, + save, restore, + }); + + save.reset(); + restore.reset(); + registry.setModel(null); + assert.equal(save.callCount, 1); + assert.equal(restore.callCount, 0); + + save.reset(); + restore.reset(); + registry.setModel(model1); + assert.equal(save.callCount, 0); + assert.equal(restore.callCount, 1); + }); + }); + + it('shares data across multiple instances given the same type and model', () => { + let data; + const registry1 = new ModelStateRegistry(Type1, { + initialModel: model1, + save: () => data, + restore: (saved = {}) => { data = saved; }, + }); + data = {some: 'data'}; + registry1.setModel(model2); + assert.deepEqual(data, {}); + data = {more: 'datas'}; + registry1.setModel(model1); + + let data2; + const registry2 = new ModelStateRegistry(Type1, { + initialModel: model1, + save: () => data2, + restore: (saved = {}) => { data2 = saved; }, + }); + assert.deepEqual(data2, {some: 'data'}); + registry2.setModel(model2); + assert.deepEqual(data2, {more: 'datas'}); + }); + + it('does not share data across multiple instances given the same model but a different type', () => { + let data; + const registry1 = new ModelStateRegistry(Type1, { + initialModel: model1, + save: () => data, + restore: (saved = {}) => { data = saved; }, + }); + data = {some: 'data'}; + registry1.setModel(model2); + assert.deepEqual(data, {}); + data = {more: 'datas'}; + registry1.setModel(model1); + + let data2; + const registry2 = new ModelStateRegistry(Type2, { + initialModel: model1, + save: () => data2, + restore: (saved = {}) => { data2 = saved; }, + }); + assert.deepEqual(data2, {}); + data2 = {evenMore: 'data'}; + registry2.setModel(model2); + assert.deepEqual(data2, {}); + registry2.setModel(model1); + assert.deepEqual(data2, {evenMore: 'data'}); + }); + + describe('#save and #restore', () => { + it('manually saves and restores data', () => { + let data; + const registry = new ModelStateRegistry(Type1, { + initialModel: model1, + save: () => data, + restore: (saved = {}) => { data = saved; }, + }); + data = {some: 'data'}; + registry.save(); + data = {}; + registry.restore(model2); + assert.deepEqual(data, {}); + registry.restore(model1); + assert.deepEqual(data, {some: 'data'}); + }); + }); +}); diff --git a/test/models/workspace-change-observer.test.js b/test/models/workspace-change-observer.test.js index 8e54dbf062..6b0edccc37 100644 --- a/test/models/workspace-change-observer.test.js +++ b/test/models/workspace-change-observer.test.js @@ -1,7 +1,6 @@ /** @babel */ import path from 'path'; -import sinon from 'sinon'; import {cloneRepository, buildRepository} from '../helpers'; diff --git a/test/multi-list.test.js b/test/multi-list.test.js index 2cd7de8494..fd70ce4d06 100644 --- a/test/multi-list.test.js +++ b/test/multi-list.test.js @@ -1,6 +1,5 @@ /** @babel */ -import sinon from 'sinon'; import MultiList from '../lib/multi-list'; describe('MultiList', () => { diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index 8da5aed677..b4c9aa978b 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -2,73 +2,57 @@ import {cloneRepository, buildRepository} from '../helpers'; import etch from 'etch'; -import sinon from 'sinon'; import CommitView from '../../lib/views/commit-view'; -import {AbortMergeError, CommitError} from '../../lib/models/repository'; describe('CommitView', () => { - let atomEnv, workspace, commandRegistry, notificationManager, confirmChoice; + let atomEnv, commandRegistry; beforeEach(() => { atomEnv = global.buildAtomEnvironment(); - workspace = atomEnv.workspace; commandRegistry = atomEnv.commands; - notificationManager = atomEnv.notifications; - confirmChoice = 0; - sinon.stub(atom, 'confirm', () => confirmChoice); }); afterEach(() => { atomEnv.destroy(); - atom.confirm.restore(); }); it('displays the remaining characters limit based on which line is being edited', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - const viewState = {}; - const view = new CommitView({workspace, repository, commandRegistry, stagedChangesExist: true, maximumCharacterLimit: 72, viewState}); - const {editor} = view.refs; + const view = new CommitView({commandRegistry, stagedChangesExist: true, maximumCharacterLimit: 72, message: ''}); assert.equal(view.refs.remainingCharacters.textContent, '72'); - editor.insertText('abcde fghij'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({message: 'abcde fghij'}); assert.equal(view.refs.remainingCharacters.textContent, '61'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); - editor.insertText('\nklmno'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({message: '\nklmno'}); assert.equal(view.refs.remainingCharacters.textContent, '∞'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); - editor.insertText('\npqrst'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({message: 'abcde\npqrst'}); assert.equal(view.refs.remainingCharacters.textContent, '∞'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); - editor.setCursorBufferPosition([0, 3]); + view.editor.setCursorBufferPosition([0, 3]); await etch.getScheduler().getNextUpdatePromise(); - assert.equal(view.refs.remainingCharacters.textContent, '61'); + assert.equal(view.refs.remainingCharacters.textContent, '67'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); await view.update({stagedChangesExist: true, maximumCharacterLimit: 50}); - assert.equal(view.refs.remainingCharacters.textContent, '39'); + assert.equal(view.refs.remainingCharacters.textContent, '45'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); - editor.insertText('abcde fghij klmno pqrst uvwxyz'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({message: 'a'.repeat(41)}); assert.equal(view.refs.remainingCharacters.textContent, '9'); assert(!view.refs.remainingCharacters.classList.contains('is-error')); assert(view.refs.remainingCharacters.classList.contains('is-warning')); - editor.insertText('ABCDE FGHIJ KLMNO'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({message: 'a'.repeat(58)}); assert.equal(view.refs.remainingCharacters.textContent, '-8'); assert(view.refs.remainingCharacters.classList.contains('is-error')); assert(!view.refs.remainingCharacters.classList.contains('is-warning')); @@ -77,14 +61,14 @@ describe('CommitView', () => { it('uses the git commit message grammar when the grammar is loaded', async () => { await atom.packages.activatePackage('language-git'); - const view = new CommitView({workspace, commandRegistry}); + const view = new CommitView({commandRegistry}); assert.equal(view.editor.getGrammar().scopeName, 'text.git-commit'); }); it('uses the git commit message grammar when the grammar has not been loaded', async () => { atom.packages.deactivatePackage('language-git'); - const view = new CommitView({workspace, commandRegistry}); + const view = new CommitView({commandRegistry}); assert(view.editor.getGrammar().scopeName.startsWith('text.plain')); await atom.packages.activatePackage('language-git'); @@ -96,7 +80,7 @@ describe('CommitView', () => { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const viewState = {}; - const view = new CommitView({workspace, repository, commandRegistry, stagedChangesExist: false, viewState}); + const view = new CommitView({repository, commandRegistry, stagedChangesExist: false, viewState}); const {editor, commitButton} = view.refs; assert.isTrue(commitButton.disabled); @@ -119,20 +103,14 @@ describe('CommitView', () => { }); it('calls props.commit(message) when the commit button is clicked or github:commit is dispatched', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); const commit = sinon.spy(); - const view = new CommitView({workspace, commandRegistry, stagedChangesExist: false, commit}); + const view = new CommitView({commandRegistry, stagedChangesExist: false, commit, message: ''}); const {editor, commitButton} = view.refs; // commit by clicking the commit button - await view.update({repository, stagedChangesExist: true}); - editor.setText('Commit 1'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({stagedChangesExist: true, message: 'Commit 1'}); commitButton.dispatchEvent(new MouseEvent('click')); - await etch.getScheduler().getNextUpdatePromise(); assert.equal(commit.args[0][0], 'Commit 1'); - assert.equal(editor.getText(), ''); // undo history is cleared commandRegistry.dispatch(editor.element, 'core:undo'); @@ -140,72 +118,25 @@ describe('CommitView', () => { // commit via the github:commit command commit.reset(); - await view.update({repository, stagedChangesExist: true}); - editor.setText('Commit 2'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({stagedChangesExist: true, message: 'Commit 2'}); commandRegistry.dispatch(editor.element, 'github:commit'); - await etch.getScheduler().getNextUpdatePromise(); assert.equal(commit.args[0][0], 'Commit 2'); - assert.equal(editor.getText(), ''); // disable github:commit when there are no staged changes... commit.reset(); - await view.update({repository, stagedChangesExist: false}); - editor.setText('Commit 4'); - await etch.getScheduler().getNextUpdatePromise(); + await view.update({stagedChangesExist: false, message: 'Commit 4'}); commandRegistry.dispatch(editor.element, 'github:commit'); - await etch.getScheduler().getNextUpdatePromise(); assert.equal(commit.callCount, 0); - assert.equal(editor.getText(), 'Commit 4'); // ...or the commit message is empty commit.reset(); - editor.setText(''); - await etch.getScheduler().getNextUpdatePromise(); - await view.update({repository, stagedChangesExist: true}); + await view.update({stagedChangesExist: true, message: ''}); commandRegistry.dispatch(editor.element, 'github:commit'); - await etch.getScheduler().getNextUpdatePromise(); assert.equal(commit.callCount, 0); }); - it('shows an error notification when props.commit() throws an ECONFLICT exception', async () => { - const commit = sinon.spy(async () => { - await Promise.resolve(); - throw new CommitError('ECONFLICT'); - }); - const view = new CommitView({workspace, commandRegistry, notificationManager, stagedChangesExist: true, commit}); - const {editor, commitButton} = view.refs; - editor.setText('A message.'); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(notificationManager.getNotifications().length, 0); - commitButton.dispatchEvent(new MouseEvent('click')); - await etch.getScheduler().getNextUpdatePromise(); - assert(commit.calledOnce); - assert.equal(editor.getText(), 'A message.'); - assert.equal(notificationManager.getNotifications().length, 1); - }); - - it('replaces the contents of the commit message when it is empty and a message is supplied from the outside', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - const viewState = {}; - const view = new CommitView({workspace, repository, commandRegistry, stagedChangesExist: true, maximumCharacterLimit: 72, viewState}); - const {editor} = view.refs; - editor.setText('message 1'); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(editor.getText(), 'message 1'); - - await view.update({message: 'Merge conflict!'}); - assert.equal(editor.getText(), 'message 1'); - - editor.setText(''); - await etch.getScheduler().getNextUpdatePromise(); - await view.update({message: 'Merge conflict!'}); - assert.equal(editor.getText(), 'Merge conflict!'); - }); - it('shows the "Abort Merge" button when props.isMerging is true', async () => { - const view = new CommitView({workspace, commandRegistry, stagedChangesExist: true, isMerging: false}); + const view = new CommitView({commandRegistry, stagedChangesExist: true, isMerging: false}); const {abortMergeButton} = view.refs; assert.equal(abortMergeButton.style.display, 'none'); @@ -216,74 +147,18 @@ describe('CommitView', () => { assert.equal(abortMergeButton.style.display, 'none'); }); - it('calls props.abortMerge() when the "Abort Merge" button is clicked and then clears the commit message', async () => { + it('calls props.abortMerge() when the "Abort Merge" button is clicked', () => { const abortMerge = sinon.spy(() => Promise.resolve()); - const view = new CommitView({workspace, commandRegistry, stagedChangesExist: true, isMerging: true, abortMerge}); - const {editor, abortMergeButton} = view.refs; - editor.setText('A message.'); + const view = new CommitView({commandRegistry, stagedChangesExist: true, isMerging: true, abortMerge}); + const {abortMergeButton} = view.refs; abortMergeButton.dispatchEvent(new MouseEvent('click')); - await etch.getScheduler().getNextUpdatePromise(); assert(abortMerge.calledOnce); - assert.equal(editor.getText(), ''); - }); - - it('shows an error notification when props.abortMerge() throws an EDIRTYSTAGED exception', async () => { - const abortMerge = sinon.spy(async () => { - await Promise.resolve(); - throw new AbortMergeError('EDIRTYSTAGED', 'a.txt'); - }); - const view = new CommitView({workspace, commandRegistry, notificationManager, stagedChangesExist: true, isMerging: true, abortMerge}); - const {editor, abortMergeButton} = view.refs; - editor.setText('A message.'); - assert.equal(notificationManager.getNotifications().length, 0); - abortMergeButton.dispatchEvent(new MouseEvent('click')); - await etch.getScheduler().getNextUpdatePromise(); - assert(abortMerge.calledOnce); - assert.equal(editor.getText(), 'A message.'); - assert.equal(notificationManager.getNotifications().length, 1); }); describe('amending', () => { - it('displays the appropriate commit message and sets the cursor to the beginning of the text', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - const viewState = {}; - const view = new CommitView({workspace, repository, commandRegistry, stagedChangesExist: false, lastCommit: {message: 'previous commit\'s message'}, viewState}); - const {editor, amend} = view.refs; - - editor.setText('some commit message'); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), 'some commit message'); - - // displays message for last commit - amend.click(); - assert.isTrue(amend.checked); - assert.equal(editor.getText(), 'previous commit\'s message'); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), [0, 0]); - - // restores original message - amend.click(); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), 'some commit message'); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), [0, 0]); - }); - - it('clears the amend checkbox after committing', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - const view = new CommitView({workspace, commandRegistry, stagedChangesExist: false}); - const {amend} = view.refs; - await view.update({repository, stagedChangesExist: true}); - assert.isFalse(amend.checked); - amend.click(); - assert.isTrue(amend.checked); - await view.commit(); - assert.isFalse(amend.checked); - }); - it('calls props.setAmending() when the box is checked or unchecked', () => { const setAmending = sinon.spy(); - const view = new CommitView({workspace, commandRegistry, stagedChangesExist: false, lastCommit: {message: 'previous commit\'s message'}, setAmending}); + const view = new CommitView({commandRegistry, stagedChangesExist: false, lastCommit: {message: 'previous commit\'s message'}, setAmending}); const {amend} = view.refs; amend.click(); @@ -293,115 +168,4 @@ describe('CommitView', () => { assert.deepEqual(setAmending.args, [[true], [false]]); }); }); - - describe('when switching between repositories', () => { - it('retains the commit message and cursor location', async () => { - const workdirPath1 = await cloneRepository('multiple-commits'); - const repository1 = await buildRepository(workdirPath1); - const workdirPath2 = await cloneRepository('three-files'); - const repository2 = await buildRepository(workdirPath2); - - const viewStateForRepo1 = {}; - const viewStateForRepo2 = {}; - - let viewForRepo1 = new CommitView({workspace, repository: repository1, commandRegistry, stagedChangesExist: true, viewState: viewStateForRepo1}); - let editor = viewForRepo1.refs.editor; - - const repository1Message = 'commit message for first repo\nsome details about the commit\nmore details'; - editor.setText(repository1Message); - const repository1CursorPosition = [1, 3]; - editor.setCursorBufferPosition(repository1CursorPosition); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(editor.getText(), repository1Message); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), repository1CursorPosition); - - let viewForRepo2 = new CommitView({workspace, repository: repository2, commandRegistry, stagedChangesExist: true, viewState: viewStateForRepo2}); - editor = viewForRepo2.refs.editor; - assert.equal(editor.getText(), ''); - - const repository2Message = 'commit message for second repo'; - editor.setText(repository2Message); - const repository2CursorPosition = [0, 10]; - editor.setCursorBufferPosition(repository2CursorPosition); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(editor.getText(), repository2Message); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), repository2CursorPosition); - - // when repository1 is selected, restore its state - viewForRepo1 = new CommitView({workspace, repository: repository1, commandRegistry, stagedChangesExist: true, viewState: viewStateForRepo1}); - editor = viewForRepo1.refs.editor; - assert.equal(editor.getText(), repository1Message); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), repository1CursorPosition); - - // when repository2 is selected, restore its state - viewForRepo2 = new CommitView({workspace, repository: repository2, commandRegistry, stagedChangesExist: true, viewState: viewStateForRepo2}); - editor = viewForRepo2.refs.editor; - assert.equal(editor.getText(), repository2Message); - assert.deepEqual(editor.getCursorBufferPosition().serialize(), repository2CursorPosition); - }); - - it('retains the amend status and restores the correct commit message when amend state is exited', async () => { - const workdirPath1 = await cloneRepository('multiple-commits'); - const repository1 = await buildRepository(workdirPath1); - const workdirPath2 = await cloneRepository('three-files'); - const repository2 = await buildRepository(workdirPath2); - - const repository1LastCommit = {message: 'first repository\'s previous commit\'s message'}; - const repository2LastCommit = {message: 'second repository\'s previous commit\'s message'}; - const viewStateForRepo1 = {}; - const viewStateForRepo2 = {}; - - const view = new CommitView({workspace, repository: repository1, lastCommit: repository1LastCommit, commandRegistry, stagedChangesExist: true, viewState: viewStateForRepo1}); - const {editor, amend} = view.refs; - - // create message for repository1 - const repository1Message = 'commit message for first repo\nsome details about the commit\nmore details'; - editor.setText(repository1Message); - await etch.getScheduler().getNextUpdatePromise(); - - // put repository1 in amend state, commit message changes to that of the last commit - amend.click(); - await etch.getScheduler().getNextUpdatePromise(); - assert.isTrue(amend.checked); - assert.equal(editor.getText(), repository1LastCommit.message); - - // when repository2 is selected, restore to initial state of unchecked amend box and empty commit message - await view.update({repository: repository2, lastCommit: repository2LastCommit, viewState: viewStateForRepo2}); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), ''); - - // create commit message for repository2 - const repository2Message = 'commit message for second repo'; - editor.setText(repository2Message); - await etch.getScheduler().getNextUpdatePromise(); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), repository2Message); - - // put repository2 in amend state, commit message changes to that of the last commit - amend.click(); - await etch.getScheduler().getNextUpdatePromise(); - assert.isTrue(amend.checked); - assert.equal(editor.getText(), repository2LastCommit.message); - - // when repository1 is selected, restore its state - await view.update({repository: repository1, viewState: viewStateForRepo1}); - assert.isTrue(amend.checked); - assert.equal(editor.getText(), repository1LastCommit.message); - - // exit amend state and restore original message for repository1 - amend.click(); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), repository1Message); - - // when repository2 is selected, restore its state - await view.update({repository: repository2, viewState: viewStateForRepo2}); - assert.isTrue(amend.checked); - assert.equal(editor.getText(), repository2LastCommit.message); - - // exit amend state and restore original message for repository2 - amend.click(); - assert.isFalse(amend.checked); - assert.equal(editor.getText(), repository2Message); - }); - }); }); diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js index 0d402a8e46..bc986b1c0d 100644 --- a/test/views/file-patch-view.test.js +++ b/test/views/file-patch-view.test.js @@ -1,6 +1,5 @@ /** @babel */ -import sinon from 'sinon'; import FilePatchView from '../../lib/views/file-patch-view'; import Hunk from '../../lib/models/hunk'; diff --git a/test/views/hunk-view.test.js b/test/views/hunk-view.test.js index e6f0c1a2b6..e7c70439ae 100644 --- a/test/views/hunk-view.test.js +++ b/test/views/hunk-view.test.js @@ -1,6 +1,5 @@ /** @babel */ -import sinon from 'sinon'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; diff --git a/test/views/list-view.test.js b/test/views/list-view.test.js index 7a1cceaeeb..cb71c4ea81 100644 --- a/test/views/list-view.test.js +++ b/test/views/list-view.test.js @@ -3,7 +3,6 @@ /* eslint react/no-unknown-property: "off" */ import etch from 'etch'; -import sinon from 'sinon'; import simulant from 'simulant'; import ListView from '../../lib/views/list-view'; diff --git a/test/views/pane-item.test.js b/test/views/pane-item.test.js index 54e136eb66..05ecbb0dc0 100644 --- a/test/views/pane-item.test.js +++ b/test/views/pane-item.test.js @@ -1,7 +1,6 @@ /** @babel */ import React from 'react'; -import sinon from 'sinon'; import PaneItem from '../../lib/views/pane-item'; diff --git a/test/views/panel.test.js b/test/views/panel.test.js index 338ce898c7..5b5bc7def7 100644 --- a/test/views/panel.test.js +++ b/test/views/panel.test.js @@ -1,7 +1,6 @@ /** @babel */ import React from 'react'; -import sinon from 'sinon'; import Panel from '../../lib/views/panel'; diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js index f69c9c9da6..9fbc6734e8 100644 --- a/test/views/staging-view.test.js +++ b/test/views/staging-view.test.js @@ -1,6 +1,5 @@ /** @babel */ -import sinon from 'sinon'; import StagingView from '../../lib/views/staging-view'; import {assertEqualSets} from '../helpers';