diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index 5fe44fd3b1..5df5933ad6 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -3,7 +3,7 @@ import React from 'react'; import {Point, Emitter} from 'atom'; import {autobind} from 'core-decorators'; -import EventWatcher from '../event-watcher'; +import Switchboard from '../switchboard'; import FilePatchView from '../views/file-patch-view'; export default class FilePatchController extends React.Component { @@ -20,11 +20,11 @@ export default class FilePatchController extends React.Component { quietlySelectItem: React.PropTypes.func.isRequired, undoLastDiscard: React.PropTypes.func.isRequired, openFiles: React.PropTypes.func.isRequired, - eventWatcher: React.PropTypes.instanceOf(EventWatcher), + switchboard: React.PropTypes.instanceOf(Switchboard), } static defaultProps = { - eventWatcher: new EventWatcher(), + switchboard: new Switchboard(), } constructor(props, context) { @@ -68,7 +68,7 @@ export default class FilePatchController extends React.Component { attemptHunkStageOperation={this.attemptHunkStageOperation} didSurfaceFile={this.didSurfaceFile} didDiveIntoCorrespondingFilePatch={this.diveIntoCorrespondingFilePatch} - eventWatcher={this.props.eventWatcher} + switchboard={this.props.switchboard} openCurrentFile={this.openCurrentFile} discardLines={this.props.discardLines} undoLastDiscard={this.undoLastDiscard} @@ -88,17 +88,22 @@ export default class FilePatchController extends React.Component { } async stageHunk(hunk) { + this.props.switchboard.didBeginStageOperation({stage: true, hunk: true}); + await this.props.repository.applyPatchToIndex( this.props.filePatch.getStagePatchForHunk(hunk), ); - this.props.eventWatcher.resolveStageOperationPromise(); + this.props.switchboard.didFinishStageOperation({stage: true, hunk: true}); } async unstageHunk(hunk) { + this.props.switchboard.didBeginStageOperation({unstage: true, hunk: true}); + await this.props.repository.applyPatchToIndex( this.props.filePatch.getUnstagePatchForHunk(hunk), ); - this.props.eventWatcher.resolveStageOperationPromise(); + + this.props.switchboard.didFinishStageOperation({unstage: true, hunk: true}); } stageOrUnstageHunk(hunk) { @@ -118,7 +123,7 @@ export default class FilePatchController extends React.Component { } this.stagingOperationInProgress = true; - this.props.eventWatcher.getPatchChangedPromise().then(() => { + this.props.switchboard.getChangePatchPromise().then(() => { this.stagingOperationInProgress = false; }); @@ -126,19 +131,23 @@ export default class FilePatchController extends React.Component { } async stageLines(lines) { + this.props.switchboard.didBeginStageOperation({stage: true, line: true}); + await this.props.repository.applyPatchToIndex( this.props.filePatch.getStagePatchForLines(lines), ); - this.props.eventWatcher.resolveStageOperationPromise(); + this.props.switchboard.didFinishStageOperation({stage: true, line: true}); } async unstageLines(lines) { + this.props.switchboard.didBeginStageOperation({unstage: true, line: true}); + await this.props.repository.applyPatchToIndex( this.props.filePatch.getUnstagePatchForLines(lines), ); - this.props.eventWatcher.resolveStageOperationPromise(); + this.props.switchboard.didFinishStageOperation({unstage: true, line: true}); } stageOrUnstageLines(lines) { @@ -158,7 +167,7 @@ export default class FilePatchController extends React.Component { } this.stagingOperationInProgress = true; - this.props.eventWatcher.getPatchChangedPromise().then(() => { + this.props.switchboard.getChangePatchPromise().then(() => { this.stagingOperationInProgress = false; }); diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index a6b7ae6877..7ccf9c26b6 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -23,6 +23,7 @@ import RepositoryConflictController from './repository-conflict-controller'; import ModelObserver from '../models/model-observer'; import ModelStateRegistry from '../models/model-state-registry'; import Conflict from '../models/conflicts/conflict'; +import Switchboard from '../switchboard'; import {copyFile, deleteFileOrFolder} from '../helpers'; import {GitError} from '../git-shell-out-strategy'; @@ -47,10 +48,12 @@ export default class RootController extends React.Component { repository: React.PropTypes.object, resolutionProgress: React.PropTypes.object, statusBar: React.PropTypes.object, + switchboard: React.PropTypes.instanceOf(Switchboard), savedState: React.PropTypes.object, } static defaultProps = { + switchboard: new Switchboard(), savedState: {}, } @@ -211,6 +214,7 @@ export default class RootController extends React.Component { openFiles={this.openFiles} discardLines={this.discardLines} undoLastDiscard={this.undoLastDiscard} + switchboard={this.props.switchboard} /> @@ -316,10 +320,14 @@ export default class RootController extends React.Component { if (activate && this.filePatchControllerPane) { this.filePatchControllerPane.activate(); } + this.props.switchboard.didFinishRender('RootController.showFilePatchForPath'); resolve(); }); } else { - this.setState({...nullFilePatchState}, resolve); + this.setState({...nullFilePatchState}, () => { + this.props.switchboard.didFinishRender('RootController.showFilePatchForPath'); + resolve(); + }); } }); } diff --git a/lib/decorators/observe-model.js b/lib/decorators/observe-model.js index ebc6c6bcec..54fd69e29f 100644 --- a/lib/decorators/observe-model.js +++ b/lib/decorators/observe-model.js @@ -45,7 +45,14 @@ export default function ObserveModelDecorator(spec) { fetchData: model => fetchData(model, this.props), didUpdate: () => { if (this.mounted) { - this.setState({modelData: this.modelObserver.getActiveModelData()}, this.resolve); + this.setState({modelData: this.modelObserver.getActiveModelData()}, () => { + /* eslint-disable react/prop-types */ + if (this.props.switchboard) { + this.props.switchboard.didFinishRender('ObserveModel.didUpdate'); + } + /* eslint-enable react/prop-types */ + this.resolve(); + }); } }, }); diff --git a/lib/event-watcher.js b/lib/event-watcher.js deleted file mode 100644 index 70d6979430..0000000000 --- a/lib/event-watcher.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Construct Promises to wait for the next occurrence of specific events that occur throughout the data refresh - * and rendering cycle. Resolve those promises when the corresponding events have been observed. - */ -export default class EventWatcher { - constructor() { - this.promises = new Map(); - } - - /* - * Retrieve a Promise that will be resolved the next time a desired event is observed. - * - * In general, you should prefer the more specific `getXyzPromise()` methods instead to avoid a proliferation of - * "magic strings." - */ - getPromise(eventName) { - const existing = this.promises.get(eventName); - if (existing !== undefined) { - return existing.promise; - } - - let resolver, rejecter; - const created = new Promise((resolve, reject) => { - resolver = resolve; - rejecter = reject; - }); - this.promises.set(eventName, { - promise: created, - resolver, - rejecter, - }); - return created; - } - - /* - * Indicate that a named event has been observed, resolving any Promises that were created for this event. Optionally - * provide a payload. - * - * In general, you should prefer the more specific `resolveXyzPromise()` methods. - */ - resolvePromise(eventName, payload) { - const existing = this.promises.get(eventName); - if (existing !== undefined) { - this.promises.delete(eventName); - existing.resolver(payload); - } - } - - /* - * Indicate that a named event has had some kind of terrible problem. - */ - rejectPromise(eventName, error) { - const existing = this.promises.get(eventName); - if (existing !== undefined) { - this.promises.delete(eventName); - existing.rejecter(error); - } - } - - /* - * Notified when a hunk or line stage or unstage operation has completed. - */ - getStageOperationPromise() { - return this.getPromise('stage-operation'); - } - - /* - * Notified when an open FilePatchView's hunks have changed. - */ - getPatchChangedPromise() { - return this.getPromise('patch-changed'); - } - - /* - * A hunk or line stage or unstage operation has completed. - */ - resolveStageOperationPromise(payload) { - this.resolvePromise('stage-operation', payload); - } - - /* - * An open FilePatchView's hunks have changed. - */ - resolvePatchChangedPromise(payload) { - this.resolvePromise('patch-changed', payload); - } -} diff --git a/lib/github-package.js b/lib/github-package.js index a06d358e96..1b384e4631 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -15,6 +15,8 @@ import StyleCalculator from './models/style-calculator'; import FilePatchController from './controllers/file-patch-controller'; import RootController from './controllers/root-controller'; import IssueishPaneItem from './atom-items/issueish-pane-item'; +import Switchboard from './switchboard'; +import yardstick from './yardstick'; import GitTimingsView from './views/git-timings-view'; const defaultState = { @@ -40,6 +42,35 @@ export default class GithubPackage { this.subscriptions = new CompositeDisposable(); this.savedState = {}; + + this.switchboard = new Switchboard(); + this.setupYardstick(); + } + + setupYardstick() { + const stagingSeries = ['stageLine', 'stageHunk', 'unstageLine', 'unstageHunk']; + + this.subscriptions.add( + this.switchboard.onDidBeginStageOperation(payload => { + if (payload.stage && payload.line) { + yardstick.begin('stageLine'); + } else if (payload.stage && payload.hunk) { + yardstick.begin('stageHunk'); + } else if (payload.unstage && payload.line) { + yardstick.begin('unstageLine'); + } else if (payload.unstage && payload.hunk) { + yardstick.begin('unstageHunk'); + } + }), + this.switchboard.onDidUpdateRepository(() => { + yardstick.mark(stagingSeries, 'update-repository'); + }), + this.switchboard.onDidFinishRender(context => { + if (context === 'RootController.showFilePatchForPath') { + yardstick.finish(stagingSeries); + } + }), + ); } activate(state = {}) { @@ -149,6 +180,7 @@ export default class GithubPackage { savedState={this.savedState.gitController} createRepositoryForProjectPath={this.createRepositoryForProjectPath} cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} + switchboard={this.switchboard} />, this.element, ); } @@ -156,7 +188,10 @@ export default class GithubPackage { async deactivate() { this.subscriptions.dispose(); if (this.destroyedRepositorySubscription) { this.destroyedRepositorySubscription.dispose(); } - await this.destroyModelsForPaths(Array.from(this.modelPromisesByProjectPath.keys())); + await Promise.all([ + this.destroyModelsForPaths(Array.from(this.modelPromisesByProjectPath.keys())), + yardstick.flush(), + ]); } consumeStatusBar(statusBar) { @@ -278,6 +313,15 @@ export default class GithubPackage { } setActiveModels(repository, resolutionProgress) { + if (this.activeRepository !== repository) { + if (this.changedRepositorySubscription) { this.changedRepositorySubscription.dispose(); } + if (repository) { + this.changedRepositorySubscription = repository.onDidUpdate(() => { + this.switchboard.didUpdateRepository(); + }); + } + } + this.activeRepository = repository; this.activeResolutionProgress = resolutionProgress; this.rerender(); diff --git a/lib/switchboard.js b/lib/switchboard.js new file mode 100644 index 0000000000..50c4e2d27d --- /dev/null +++ b/lib/switchboard.js @@ -0,0 +1,74 @@ +import {Emitter} from 'atom'; // FIXME import from event-kit instead + +/* + * Register callbacks and construct Promises to wait for the next occurrence of specific events that occur throughout + * the data refresh and rendering cycle. + */ +export default class Switchboard { + constructor() { + this.promises = new Map(); + this.emitter = new Emitter(); + } + + /* + * Invoke a callback each time that a desired event is observed. Return a Disposable that can be used to + * unsubscribe from events. + * + * In general, you should use the more specific `onDidXyz` methods. + */ + onDid(eventName, callback) { + return this.emitter.on(`did-${eventName}`, callback); + } + + /* + * Indicate that a named event has been observed, firing any callbacks and resolving any Promises that were created + * for this event. Optionally provide a payload with more information. + * + * In general, you should prefer the more specific `didXyz()` methods. + */ + did(eventName, payload) { + this.emitter.emit(`did-${eventName}`, payload); + } + + /* + * Retrieve a Promise that will be resolved the next time a desired event is observed. + * + * In general, you should prefer the more specific `getXyzPromise()` methods. + */ + getPromise(eventName) { + const existing = this.promises.get(eventName); + if (existing !== undefined) { + return existing; + } + + const created = new Promise((resolve, reject) => { + const subscription = this.onDid(eventName, payload => { + subscription.dispose(); + this.promises.delete(eventName); + resolve(payload); + }); + }); + this.promises.set(eventName, created); + return created; + } +} + +[ + 'UpdateRepository', + 'BeginStageOperation', + 'FinishStageOperation', + 'ChangePatch', + 'FinishRender', +].forEach(eventName => { + Switchboard.prototype[`did${eventName}`] = function(payload) { + this.did(eventName, payload); + }; + + Switchboard.prototype[`get${eventName}Promise`] = function() { + return this.getPromise(eventName); + }; + + Switchboard.prototype[`onDid${eventName}`] = function(callback) { + return this.onDid(eventName, callback); + }; +}); diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js index b60d17ffd0..5f69f596fc 100644 --- a/lib/views/file-patch-view.js +++ b/lib/views/file-patch-view.js @@ -7,7 +7,7 @@ import {autobind} from 'core-decorators'; import HunkView from './hunk-view'; import Commands, {Command} from './commands'; import FilePatchSelection from './file-patch-selection'; -import EventWatcher from '../event-watcher'; +import Switchboard from '../switchboard'; export default class FilePatchView extends React.Component { static propTypes = { @@ -24,11 +24,11 @@ export default class FilePatchView extends React.Component { openCurrentFile: React.PropTypes.func.isRequired, didSurfaceFile: React.PropTypes.func.isRequired, didDiveIntoCorrespondingFilePatch: React.PropTypes.func.isRequired, - eventWatcher: React.PropTypes.instanceOf(EventWatcher), + switchboard: React.PropTypes.instanceOf(Switchboard), } static defaultProps = { - eventWatcher: new EventWatcher(), + switchboard: new Switchboard(), } constructor(props, context) { @@ -57,7 +57,7 @@ export default class FilePatchView extends React.Component { selection: prevState.selection.updateHunks(nextProps.hunks), }; }, () => { - nextProps.eventWatcher.resolvePatchChangedPromise(); + nextProps.switchboard.didChangePatch(); }); } } diff --git a/lib/yardstick.js b/lib/yardstick.js new file mode 100644 index 0000000000..dd84fee039 --- /dev/null +++ b/lib/yardstick.js @@ -0,0 +1,186 @@ +// Measure elapsed durations from specific beginning points. + +import fs from 'fs-extra'; +import path from 'path'; +import {writeFile} from './helpers'; + +// The maximum number of marks within a single DurationSet. A DurationSet will be automatically finished if this many +// marks are recorded. +const MAXIMUM_MARKS = 100; + +// Flush all non-active DurationSets to disk each time that this many marks have been accumulated. +const PERSIST_INTERVAL = 1000; + +// A sequence of durations measured from a fixed beginning point. +class DurationSet { + constructor(name) { + this.name = name; + this.startTimestamp = performance.now(); + this.marks = []; + this.markCount = 0; + + if (atom.config.get('github.performanceToConsole')) { + // eslint-disable-next-line no-console + console.log('%cbegin %c%s:begin', + 'font-weight: bold', + 'font-weight: normal; font-style: italic; color: blue', this.name); + } + + if (atom.config.get('github.performanceToProfile')) { + // eslint-disable-next-line no-console + console.profile(this.name); + } + } + + mark(eventName) { + const duration = performance.now() - this.startTimestamp; + + if (atom.config.get('github.performanceToConsole')) { + // eslint-disable-next-line no-console + console.log('%cmark %c%s:%s %c%dms', + 'font-weight: bold', + 'font-weight: normal; font-style: italic; color: blue', this.name, eventName, + 'font-style: normal; color: black', duration); + } + + if (atom.config.get('github.performanceToDirectory') !== '') { + this.marks.push({eventName, duration}); + } + + this.markCount++; + if (this.markCount >= MAXIMUM_MARKS) { + this.finish(); + } + } + + finish() { + this.mark('finish'); + + if (atom.config.get('github.performanceToProfile')) { + // eslint-disable-next-line no-console + console.profileEnd(this.name); + } + } + + serialize() { + return { + name: this.name, + markers: this.marks, + }; + } + + getCount() { + return this.marks.length; + } +} + +let durationSets = []; +let totalMarkCount = 0; +const activeSets = new Map(); + +function shouldCapture(seriesName, eventName) { + const anyActive = ['Console', 'Directory', 'Profile'].some(kind => { + const value = atom.config.get(`github.performanceTo${kind}`); + return value !== '' && value !== false; + }); + if (!anyActive) { + return false; + } + + const mask = new RegExp(atom.config.get('github.performanceMask')); + if (!mask.test(`${seriesName}:${eventName}`)) { + return false; + } + + return true; +} + +const yardstick = { + async save() { + const destDir = atom.config.get('github.performanceToDirectory'); + if (destDir === '' || destDir === undefined || destDir === null) { + return; + } + const fileName = path.join(destDir, `performance-${Date.now()}.json`); + + await new Promise((resolve, reject) => { + fs.ensureDir(destDir, err => (err ? reject(err) : resolve())); + }); + + const payload = JSON.stringify(durationSets.map(set => set.serialize())); + await writeFile(fileName, payload); + + if (atom.config.get('github.performanceToConsole')) { + // eslint-disable-next-line no-console + console.log('%csaved %c%d series to %s', + 'font-weight: bold', + 'font-weight: normal; color: black', durationSets.length, fileName); + } + + durationSets = []; + }, + + begin(seriesName) { + if (!shouldCapture(seriesName, 'begin')) { + return; + } + + const ds = new DurationSet(seriesName); + activeSets.set(seriesName, ds); + }, + + mark(seriesName, eventName) { + if (seriesName instanceof Array) { + for (let i = 0; i < seriesName.length; i++) { + this.mark(seriesName[i], eventName); + } + return; + } + + if (!shouldCapture(seriesName, eventName)) { + return; + } + + const ds = activeSets.get(seriesName); + if (ds === undefined) { + return; + } + ds.mark(eventName); + }, + + finish(seriesName) { + if (seriesName instanceof Array) { + for (let i = 0; i < seriesName.length; i++) { + this.finish(seriesName[i]); + } + return; + } + + if (!shouldCapture(seriesName, 'finish')) { + return; + } + + const ds = activeSets.get(seriesName); + if (ds === undefined) { + return; + } + ds.finish(); + + durationSets.push(ds); + activeSets.delete(seriesName); + + totalMarkCount += ds.getCount(); + if (totalMarkCount >= PERSIST_INTERVAL) { + totalMarkCount = 0; + this.save(); + } + }, + + async flush() { + durationSets.push(...activeSets.values()); + activeSets.clear(); + await this.save(); + }, +}; + +export default yardstick; diff --git a/package.json b/package.json index cebf776ce0..36a28ef378 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,29 @@ "type": "boolean", "default": false, "description": "Write detailed diagnostic information about git operations to the console" + }, + "performanceMask": { + "type": "array", + "default": [".*"], + "items": { + "type": "string" + }, + "description": "Performance event stream patterns to capture" + }, + "performanceToConsole": { + "type": "boolean", + "default": false, + "description": "Log performance data to the console" + }, + "performanceToDirectory": { + "type": "string", + "default": "", + "description": "Log performance data to JSON files in a directory" + }, + "performanceToProfile": { + "type": "boolean", + "default": false, + "description": "Capture CPU profiles" } }, "deserializers": { diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js index f228a9ee1d..96012fcbef 100644 --- a/test/controllers/file-patch-controller.test.js +++ b/test/controllers/file-patch-controller.test.js @@ -9,18 +9,18 @@ import FilePatch from '../../lib/models/file-patch'; import FilePatchController from '../../lib/controllers/file-patch-controller'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; -import EventWatcher from '../../lib/event-watcher'; +import Switchboard from '../../lib/switchboard'; describe('FilePatchController', function() { let atomEnv, commandRegistry; - let component, eventWatcher; + let component, switchboard; let discardLines, didSurfaceFile, didDiveIntoFilePath, quietlySelectItem, undoLastDiscard, openFiles; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); commandRegistry = atomEnv.commands; - eventWatcher = new EventWatcher(); + switchboard = new Switchboard(); discardLines = sinon.spy(); didSurfaceFile = sinon.spy(); @@ -38,7 +38,7 @@ describe('FilePatchController', function() { stagingStatus="unstaged" isPartiallyStaged={false} isAmending={false} - eventWatcher={eventWatcher} + switchboard={switchboard} discardLines={discardLines} didSurfaceFile={didSurfaceFile} didDiveIntoFilePath={didDiveIntoFilePath} @@ -169,7 +169,7 @@ describe('FilePatchController', function() { const hunkView0 = wrapper.find('HunkView').at(0); assert.isFalse(hunkView0.prop('isSelected')); - const opPromise0 = eventWatcher.getStageOperationPromise(); + const opPromise0 = switchboard.getFinishStageOperationPromise(); hunkView0.find('button.github-HunkView-stageButton').simulate('click'); await opPromise0; @@ -181,7 +181,7 @@ describe('FilePatchController', function() { ); assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedStagedLines.join('\n')); - const updatePromise0 = eventWatcher.getPatchChangedPromise(); + const updatePromise0 = switchboard.getChangePatchPromise(); const stagedFilePatch = await repository.getFilePatchForPath('sample.js', {staged: true}); wrapper.setProps({ filePatch: stagedFilePatch, @@ -190,7 +190,7 @@ describe('FilePatchController', function() { await updatePromise0; const hunkView1 = wrapper.find('HunkView').at(0); - const opPromise1 = eventWatcher.getStageOperationPromise(); + const opPromise1 = switchboard.getFinishStageOperationPromise(); hunkView1.find('button.github-HunkView-stageButton').simulate('click'); await opPromise1; @@ -221,7 +221,7 @@ describe('FilePatchController', function() { repository, })); - const opPromise0 = eventWatcher.getStageOperationPromise(); + const opPromise0 = switchboard.getFinishStageOperationPromise(); const hunkView0 = wrapper.find('HunkView').at(0); hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1}); hunkView0.find('LineView').at(3).simulate('mousemove', {}); @@ -238,12 +238,12 @@ describe('FilePatchController', function() { assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines0.join('\n')); // stage remaining lines in hunk - const updatePromise1 = eventWatcher.getPatchChangedPromise(); + const updatePromise1 = switchboard.getChangePatchPromise(); const unstagedFilePatch1 = await repository.getFilePatchForPath('sample.js'); wrapper.setProps({filePatch: unstagedFilePatch1}); await updatePromise1; - const opPromise1 = eventWatcher.getStageOperationPromise(); + const opPromise1 = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); await opPromise1; @@ -257,7 +257,7 @@ describe('FilePatchController', function() { assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines1.join('\n')); // unstage a subset of lines from the first hunk - const updatePromise2 = eventWatcher.getPatchChangedPromise(); + const updatePromise2 = switchboard.getChangePatchPromise(); const stagedFilePatch2 = await repository.getFilePatchForPath('sample.js', {staged: true}); wrapper.setProps({ filePatch: stagedFilePatch2, @@ -271,7 +271,7 @@ describe('FilePatchController', function() { hunkView2.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1, metaKey: true}); window.dispatchEvent(new MouseEvent('mouseup')); - const opPromise2 = eventWatcher.getStageOperationPromise(); + const opPromise2 = switchboard.getFinishStageOperationPromise(); hunkView2.find('button.github-HunkView-stageButton').simulate('click'); await opPromise2; @@ -284,7 +284,7 @@ describe('FilePatchController', function() { assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines2.join('\n')); // unstage the rest of the hunk - const updatePromise3 = eventWatcher.getPatchChangedPromise(); + const updatePromise3 = switchboard.getChangePatchPromise(); const stagedFilePatch3 = await repository.getFilePatchForPath('sample.js', {staged: true}); wrapper.setProps({ filePatch: stagedFilePatch3, @@ -293,7 +293,7 @@ describe('FilePatchController', function() { commandRegistry.dispatch(wrapper.find('FilePatchView').getDOMNode(), 'github:toggle-patch-selection-mode'); - const opPromise3 = eventWatcher.getStageOperationPromise(); + const opPromise3 = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); await opPromise3; @@ -317,7 +317,7 @@ describe('FilePatchController', function() { repository, })); - const opPromise = eventWatcher.getStageOperationPromise(); + const opPromise = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); await opPromise; @@ -344,7 +344,7 @@ describe('FilePatchController', function() { commandRegistry.dispatch(viewNode, 'github:toggle-patch-selection-mode'); commandRegistry.dispatch(viewNode, 'core:select-all'); - const opPromise = eventWatcher.getStageOperationPromise(); + const opPromise = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); await opPromise; @@ -384,7 +384,7 @@ describe('FilePatchController', function() { // stage lines in rapid succession // second stage action is a no-op since the first staging operation is in flight - const line1StagingPromise = eventWatcher.getStageOperationPromise(); + const line1StagingPromise = switchboard.getFinishStageOperationPromise(); hunkView0.find('.github-HunkView-stageButton').simulate('click'); hunkView0.find('.github-HunkView-stageButton').simulate('click'); await line1StagingPromise; @@ -399,7 +399,7 @@ describe('FilePatchController', function() { let actualLines = await repository.readFileFromIndex('sample.js'); assert.autocrlfEqual(actualLines, expectedLines.join('\n')); - const line1PatchPromise = eventWatcher.getPatchChangedPromise(); + const line1PatchPromise = switchboard.getChangePatchPromise(); wrapper.setProps({filePatch: modifiedFilePatch}); await line1PatchPromise; @@ -407,7 +407,7 @@ describe('FilePatchController', function() { hunkView1.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1}); window.dispatchEvent(new MouseEvent('mouseup')); - const line2StagingPromise = eventWatcher.getStageOperationPromise(); + const line2StagingPromise = switchboard.getFinishStageOperationPromise(); hunkView1.find('.github-HunkView-stageButton').simulate('click'); await line2StagingPromise; @@ -446,12 +446,12 @@ describe('FilePatchController', function() { // ensure staging the same hunk twice does not cause issues // second stage action is a no-op since the first staging operation is in flight - const hunk1StagingPromise = eventWatcher.getStageOperationPromise(); + const hunk1StagingPromise = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); await hunk1StagingPromise; - const patchPromise0 = eventWatcher.getPatchChangedPromise(); + const patchPromise0 = switchboard.getChangePatchPromise(); repository.refresh(); // clear the cached file patches const modifiedFilePatch = await repository.getFilePatchForPath('sample.js'); wrapper.setProps({filePatch: modifiedFilePatch}); @@ -466,7 +466,7 @@ describe('FilePatchController', function() { let actualLines = await repository.readFileFromIndex('sample.js'); assert.autocrlfEqual(actualLines, expectedLines.join('\n')); - const hunk2StagingPromise = eventWatcher.getStageOperationPromise(); + const hunk2StagingPromise = switchboard.getFinishStageOperationPromise(); wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); await hunk2StagingPromise; diff --git a/test/event-watcher.test.js b/test/event-watcher.test.js deleted file mode 100644 index fbd4a7fe16..0000000000 --- a/test/event-watcher.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import EventWatcher from '../lib/event-watcher'; - -describe('EventWatcher', function() { - let watcher; - - beforeEach(function() { - watcher = new EventWatcher(); - }); - - it('creates and resolves a Promise for an event', async function() { - const promise = watcher.getPromise('testing'); - - const payload = {}; - watcher.resolvePromise('testing', payload); - - const result = await promise; - assert.strictEqual(result, payload); - }); - - it('supports multiple consumers of the same Promise', async function() { - const promise0 = watcher.getPromise('testing'); - const promise1 = watcher.getPromise('testing'); - assert.strictEqual(promise0, promise1); - - const payload = {}; - watcher.resolvePromise('testing', payload); - - assert.strictEqual(await promise0, payload); - assert.strictEqual(await promise1, payload); - }); - - it('creates new Promises for repeated events', async function() { - const promise0 = watcher.getPromise('testing'); - - watcher.resolvePromise('testing', 0); - assert.equal(await promise0, 0); - - const promise1 = watcher.getPromise('testing'); - - watcher.resolvePromise('testing', 1); - assert.equal(await promise1, 1); - }); - - it('"resolves" an event that has no Promise', function() { - watcher.resolvePromise('anybody-there', {}); - }); - - it('rejects a Promise with an error', async function() { - const promise = watcher.getPromise('testing'); - - watcher.rejectPromise('testing', new Error('oh shit')); - await assert.isRejected(promise, /oh shit/); - }); - - describe('function pairs', function() { - const baseNames = Object.getOwnPropertyNames(EventWatcher.prototype) - .map(methodName => /^get(.+)Promise$/.exec(methodName)) - .filter(match => match !== null) - .map(match => match[1]); - let functionPairs; - - beforeEach(function() { - functionPairs = baseNames.map(baseName => { - return { - baseName, - getter: watcher[`get${baseName}Promise`].bind(watcher), - resolver: watcher[`resolve${baseName}Promise`].bind(watcher), - }; - }); - }); - - baseNames.forEach(baseName => { - it(`resolves the correct Promise for ${baseName}`, async function() { - const allPromises = []; - const positiveResults = []; - const negativeResults = []; - - let positiveResolver = null; - const negativeResolvers = []; - - for (let i = 0; i < functionPairs.length; i++) { - const functionPair = functionPairs[i]; - - if (functionPair.baseName === baseName) { - const positivePromise = functionPair.getter().then(payload => { - positiveResults.push(payload); - }); - allPromises.push(positivePromise); - - positiveResolver = functionPair.resolver; - } else { - const negativePromise = functionPair.getter().then(payload => { - negativeResults.push(payload); - }); - allPromises.push(negativePromise); - - negativeResolvers.push(functionPair.resolver); - } - } - - // Resolve positive resolvers with "yes" and negative resolvers with "no" - positiveResolver('yes'); - negativeResolvers.forEach(resolver => resolver('no')); - - await Promise.all(allPromises); - - assert.lengthOf(positiveResults, 1); - assert.isTrue(positiveResults.every(result => result === 'yes')); - - assert.lengthOf(negativeResults, baseNames.length - 1); - assert.isTrue(negativeResults.every(result => result === 'no')); - }); - }); - }); -}); diff --git a/test/switchboard.test.js b/test/switchboard.test.js new file mode 100644 index 0000000000..1403a6634e --- /dev/null +++ b/test/switchboard.test.js @@ -0,0 +1,140 @@ +import {CompositeDisposable} from 'atom'; // FIXME import from event-kit +import Switchboard from '../lib/switchboard'; + +describe('Switchboard', function() { + let switchboard; + + beforeEach(function() { + switchboard = new Switchboard(); + }); + + describe('events', function() { + let sub; + + afterEach(function() { + sub && sub.dispose(); + }); + + it('synchronously broadcasts events', function() { + let observed = 0; + sub = switchboard.onDid('test', () => observed++); + + assert.equal(observed, 0); + switchboard.did('test'); + assert.equal(observed, 1); + }); + }); + + describe('promises', function() { + it('creates and resolves a Promise for an event', async function() { + const promise = switchboard.getPromise('testing'); + + const payload = {}; + switchboard.did('testing', payload); + + const result = await promise; + assert.strictEqual(result, payload); + }); + + it('supports multiple consumers of the same Promise', async function() { + const promise0 = switchboard.getPromise('testing'); + const promise1 = switchboard.getPromise('testing'); + assert.strictEqual(promise0, promise1); + + const payload = {}; + switchboard.did('testing', payload); + + assert.strictEqual(await promise0, payload); + assert.strictEqual(await promise1, payload); + }); + + it('creates new Promises for repeated events', async function() { + const promise0 = switchboard.getPromise('testing'); + + switchboard.did('testing', 0); + assert.equal(await promise0, 0); + + const promise1 = switchboard.getPromise('testing'); + + switchboard.did('testing', 1); + assert.equal(await promise1, 1); + }); + + it('"resolves" an event that has no Promise', function() { + switchboard.did('anybody-there', {}); + }); + }); + + // Ensure that all of the `didXyz`, `onDidXyz`, and `getXyzPromise` method triplets are aligned correctly. + describe('function triplets', function() { + const baseNames = Object.getOwnPropertyNames(Switchboard.prototype) + .map(methodName => /^did(.+)$/.exec(methodName)) + .filter(match => match !== null) + .map(match => match[1]); + let functionTriples; + + beforeEach(function() { + functionTriples = baseNames.map(baseName => { + return { + baseName, + subscriber: switchboard[`onDid${baseName}`].bind(switchboard), + getter: switchboard[`get${baseName}Promise`].bind(switchboard), + resolver: switchboard[`did${baseName}`].bind(switchboard), + }; + }); + }); + + baseNames.forEach(baseName => { + it(`resolves the correct Promise for ${baseName}`, async function() { + const allPromises = []; + const positiveResults = []; + const negativeResults = []; + + let positiveResolver = null; + const negativeResolvers = []; + + const subscriptions = new CompositeDisposable(); + + for (let i = 0; i < functionTriples.length; i++) { + const functionTriple = functionTriples[i]; + + if (functionTriple.baseName === baseName) { + const positivePromise = functionTriple.getter().then(payload => { + positiveResults.push(payload); + }); + allPromises.push(positivePromise); + + positiveResolver = functionTriple.resolver; + + const positiveSubscription = functionTriple.subscriber(payload => positiveResults.push(payload)); + subscriptions.add(positiveSubscription); + } else { + const negativePromise = functionTriple.getter().then(payload => { + negativeResults.push(payload); + }); + allPromises.push(negativePromise); + + negativeResolvers.push(functionTriple.resolver); + + const negativeSubscription = functionTriple.subscriber(payload => negativeResults.push(payload)); + subscriptions.add(negativeSubscription); + } + } + + // Resolve positive resolvers with "yes" and negative resolvers with "no" + positiveResolver('yes'); + negativeResolvers.forEach(resolver => resolver('no')); + + await Promise.all(allPromises); + + subscriptions.dispose(); + + assert.lengthOf(positiveResults, 2); + assert.isTrue(positiveResults.every(result => result === 'yes')); + + assert.lengthOf(negativeResults, (baseNames.length - 1) * 2); + assert.isTrue(negativeResults.every(result => result === 'no')); + }); + }); + }); +});