Mailspring/spec/stores/draft-store-spec.es6

554 lines
19 KiB
Plaintext
Raw Normal View History

import {
Thread,
Actions,
Contact,
Message,
Account,
DraftStore,
DraftHelpers,
DatabaseStore,
SoundRegistry,
DestroyDraftTask,
ComposerExtension,
ExtensionRegistry,
FocusedContentStore,
DatabaseTransaction,
} from 'nylas-exports';
2016-05-07 05:10:28 +08:00
import {remote} from 'electron';
import DraftFactory from '../../src/flux/stores/draft-factory';
class TestExtension extends ComposerExtension {
static prepareNewDraft({draft}) {
2016-05-07 05:10:28 +08:00
draft.body = `Edited by TestExtension! ${draft.body}`;
}
}
describe('DraftStore', function draftStore() {
beforeEach(() => {
this.fakeThread = new Thread({id: 'fake-thread', clientId: 'fake-thread'});
this.fakeMessage = new Message({id: 'fake-message', clientId: 'fake-message'});
spyOn(NylasEnv, 'newWindow').andCallFake(() => {});
spyOn(DatabaseTransaction.prototype, "persistModel").andReturn(Promise.resolve());
spyOn(DatabaseStore, 'run').andCallFake((query) => {
if (query._klass === Thread) { return Promise.resolve(this.fakeThread); }
if (query._klass === Message) { return Promise.resolve(this.fakeMessage); }
if (query._klass === Contact) { return Promise.resolve(null); }
return Promise.reject(new Error(`Not Stubbed for class ${query._klass.name}`));
});
for (const draftClientId of Object.keys(DraftStore._draftSessions)) {
const sess = DraftStore._draftSessions[draftClientId];
if (sess.teardown) {
DraftStore._doneWithSession(sess);
}
}
DraftStore._draftSessions = {};
});
describe("creating and opening drafts", () => {
beforeEach(() => {
const draft = new Message({id: "A", subject: "B", clientId: "A", body: "123"});
this.newDraft = draft;
spyOn(DraftFactory, "createDraftForReply").andReturn(Promise.resolve(draft));
spyOn(DraftFactory, "createOrUpdateDraftForReply").andReturn(Promise.resolve(draft));
spyOn(DraftFactory, "createDraftForForward").andReturn(Promise.resolve(draft));
spyOn(DraftFactory, "createDraft").andReturn(Promise.resolve(draft));
});
it("should always attempt to focus the new draft", () => {
spyOn(Actions, 'focusDraft')
DraftStore._onComposeReply({
threadId: this.fakeThread.id,
messageId: this.fakeMessage.id,
type: 'reply',
behavior: 'prefer-existing',
});
advanceClock();
advanceClock();
expect(Actions.focusDraft).toHaveBeenCalled();
});
describe("context", () => {
it("can accept IDs for thread and message arguments", () => {
DraftStore._onComposeReply({
threadId: this.fakeThread.id,
messageId: this.fakeMessage.id,
type: 'reply',
behavior: 'prefer-existing',
});
advanceClock();
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
thread: this.fakeThread,
message: this.fakeMessage,
type: 'reply',
behavior: 'prefer-existing',
});
});
it("can accept models for thread and message arguments", () => {
DraftStore._onComposeReply({
thread: this.fakeThread,
message: this.fakeMessage,
type: 'reply',
behavior: 'prefer-existing',
});
advanceClock();
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
thread: this.fakeThread,
message: this.fakeMessage,
type: 'reply',
behavior: 'prefer-existing',
});
});
it("can accept only a thread / threadId, and use the last message on the thread", () => {
DraftStore._onComposeReply({
thread: this.fakeThread,
type: 'reply',
behavior: 'prefer-existing',
});
advanceClock();
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
thread: this.fakeThread,
message: this.fakeMessage,
type: 'reply',
behavior: 'prefer-existing',
});
});
});
describe("popout behavior", () => {
it("can popout a reply", () => {
runs(() => {
DraftStore._onComposeReply({
threadId: this.fakeThread.id,
messageId: this.fakeMessage.id,
type: 'reply',
popout: true}
);
});
waitsFor(() => {
return DatabaseTransaction.prototype.persistModel.callCount > 0;
});
runs(() => {
expect(NylasEnv.newWindow).toHaveBeenCalledWith({
title: 'Message',
feat(win): faster popout windows Summary: This diff is designed to dramatically speed up new window load time for all window types and reduce memory consumption of our hot windows. Before this diff, windows loaded in ~3 seconds. They now boot in a couple hundred milliseconds without requiring to keep hot windows around for each and every type of popout window we want to load quickly. One of the largest bottlenecks was the `require`ing and initializing of everything in `NylasExports`. I changed `NylasExports` to be entirely lazily-loaded. Drafts and tasks now register their constructors with a `StoreRegistry` and the `TaskRegistry`. This lets us explicitly choose a time to activate these stores in the window initalization instead of whenever nylas-exports happens to be required first. Before, NylasExports was required first when components were first rendering. This made initial render extremely slow and made the proposed time picker popout slow. By moving require into the very initial window boot, we can create a new scheme of hot windows that are "half loaded". All of the expensive require-ing and store initialization is done. All we need to do is activate the packages for just the one window. This means that the hot window scheme needs to fundamentally change from have fully pre-loaded windows, to having half-loaded empty hot windows that can get their window props overridden again. This led to a major refactor of the WindowManager to support this new window scheme. Along the way the API of WindowManager was significantly simplifed. Instead of a bunch of special-cased windows, there are now consistent interfaces to get and `ensure` windows are created and displayed. This DRYed up a lot of repeated logic around showing or creating core windows. This also allowed the consolidation of the core window configurations into one place for much easier reasoning about what's getting booted up. When a hot window goes "live" and gets populated, we simply change the `windowType`. This now re-triggers the loading of all of the packages for the window. All of the loading time is now just for the packages that window requires since core Nylas is there thanks to the hot window mechanism. Unfortunately loading all of the packages for the composer was still unnaceptably slow. The major issue was that all of the composer plugins were taking a long time to process and initialize. The solution was to have the main composer load first, then trigger another window load settings change to change the `windowType` that loads in all of the plugins. Another major bottleneck was the `RetinaImg` name lookup on disk. This requires traversing the entire static folder synchronously on boot. This is now done once when the main window loads and saved in a cache in the browser process. Any secondary windows simply ask the backend for this cache and save the filesystem access time. The Paper Doc below is the current set of manual tests I'm doing to make sure no window interactions (there are a lot of them!) regressed. Test Plan: https://paper.dropbox.com/doc/Window-Refactor-UYsgvjgdXgVlTw8nXTr9h Reviewers: juan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2916
2016-04-23 04:30:42 +08:00
hidden: true,
windowKey: `composer-A`,
windowType: "composer-preload",
windowProps: { draftClientId: "A", draftJSON: this.newDraft.toJSON() },
});
});
});
it("can popout a forward", () => {
runs(() => {
DraftStore._onComposeForward({
threadId: this.fakeThread.id,
messageId: this.fakeMessage.id,
popout: true,
});
});
waitsFor(() => {
return DatabaseTransaction.prototype.persistModel.callCount > 0;
});
runs(() => {
expect(NylasEnv.newWindow).toHaveBeenCalledWith({
title: 'Message',
feat(win): faster popout windows Summary: This diff is designed to dramatically speed up new window load time for all window types and reduce memory consumption of our hot windows. Before this diff, windows loaded in ~3 seconds. They now boot in a couple hundred milliseconds without requiring to keep hot windows around for each and every type of popout window we want to load quickly. One of the largest bottlenecks was the `require`ing and initializing of everything in `NylasExports`. I changed `NylasExports` to be entirely lazily-loaded. Drafts and tasks now register their constructors with a `StoreRegistry` and the `TaskRegistry`. This lets us explicitly choose a time to activate these stores in the window initalization instead of whenever nylas-exports happens to be required first. Before, NylasExports was required first when components were first rendering. This made initial render extremely slow and made the proposed time picker popout slow. By moving require into the very initial window boot, we can create a new scheme of hot windows that are "half loaded". All of the expensive require-ing and store initialization is done. All we need to do is activate the packages for just the one window. This means that the hot window scheme needs to fundamentally change from have fully pre-loaded windows, to having half-loaded empty hot windows that can get their window props overridden again. This led to a major refactor of the WindowManager to support this new window scheme. Along the way the API of WindowManager was significantly simplifed. Instead of a bunch of special-cased windows, there are now consistent interfaces to get and `ensure` windows are created and displayed. This DRYed up a lot of repeated logic around showing or creating core windows. This also allowed the consolidation of the core window configurations into one place for much easier reasoning about what's getting booted up. When a hot window goes "live" and gets populated, we simply change the `windowType`. This now re-triggers the loading of all of the packages for the window. All of the loading time is now just for the packages that window requires since core Nylas is there thanks to the hot window mechanism. Unfortunately loading all of the packages for the composer was still unnaceptably slow. The major issue was that all of the composer plugins were taking a long time to process and initialize. The solution was to have the main composer load first, then trigger another window load settings change to change the `windowType` that loads in all of the plugins. Another major bottleneck was the `RetinaImg` name lookup on disk. This requires traversing the entire static folder synchronously on boot. This is now done once when the main window loads and saved in a cache in the browser process. Any secondary windows simply ask the backend for this cache and save the filesystem access time. The Paper Doc below is the current set of manual tests I'm doing to make sure no window interactions (there are a lot of them!) regressed. Test Plan: https://paper.dropbox.com/doc/Window-Refactor-UYsgvjgdXgVlTw8nXTr9h Reviewers: juan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2916
2016-04-23 04:30:42 +08:00
hidden: true,
windowKey: `composer-A`,
windowType: "composer-preload",
windowProps: { draftClientId: "A", draftJSON: this.newDraft.toJSON() },
});
});
});
});
});
describe("onDestroyDraft", () => {
beforeEach(() => {
this.draftSessionTeardown = jasmine.createSpy('draft teardown');
this.session =
{draft() {
return {pristine: false};
},
changes:
{commit() { return Promise.resolve(); },
teardown() {},
},
teardown: this.draftSessionTeardown,
};
2016-05-07 05:10:28 +08:00
DraftStore._draftSessions = {abc: this.session};
spyOn(Actions, 'queueTask');
});
it("should teardown the draft session, ensuring no more saves are made", () => {
DraftStore._onDestroyDraft('abc');
expect(this.draftSessionTeardown).toHaveBeenCalled();
});
it("should not throw if the draft session is not in the window", () => {
expect(() => DraftStore._onDestroyDraft('other')).not.toThrow();
});
it("should queue a destroy draft task", () => {
DraftStore._onDestroyDraft('abc');
expect(Actions.queueTask).toHaveBeenCalled();
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true);
});
it("should clean up the draft session", () => {
spyOn(DraftStore, '_doneWithSession');
DraftStore._onDestroyDraft('abc');
expect(DraftStore._doneWithSession).toHaveBeenCalledWith(this.session);
});
it("should close the window if it's a popout", () => {
spyOn(NylasEnv, "close");
spyOn(DraftStore, "_isPopout").andReturn(true);
DraftStore._onDestroyDraft('abc');
expect(NylasEnv.close).toHaveBeenCalled();
});
it("should NOT close the window if isn't a popout", () => {
spyOn(NylasEnv, "close");
spyOn(DraftStore, "_isPopout").andReturn(false);
DraftStore._onDestroyDraft('abc');
expect(NylasEnv.close).not.toHaveBeenCalled();
});
});
describe("before unloading", () => {
it("should destroy pristine drafts", () => {
2016-05-07 05:10:28 +08:00
DraftStore._draftSessions = {
abc: {
changes: {},
draft() {
return {pristine: true};
},
},
2016-05-07 05:10:28 +08:00
};
spyOn(Actions, 'queueTask');
DraftStore._onBeforeUnload();
expect(Actions.queueTask).toHaveBeenCalled();
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true);
});
describe("when drafts return unresolved commit promises", () => {
beforeEach(() => {
this.resolve = null;
DraftStore._draftSessions = {
2016-05-07 05:10:28 +08:00
abc: {
changes: {
2016-05-07 05:10:28 +08:00
commit: () => new Promise((resolve) => { this.resolve = resolve }),
},
draft() {
return {pristine: false};
},
},
};
});
it("should return false and call window.close itself", () => {
const callback = jasmine.createSpy('callback');
expect(DraftStore._onBeforeUnload(callback)).toBe(false);
expect(callback).not.toHaveBeenCalled();
this.resolve();
advanceClock(1000);
advanceClock(1000);
expect(callback).toHaveBeenCalled();
});
});
describe("when drafts return immediately fulfilled commit promises", () => {
beforeEach(() => {
2016-05-07 05:10:28 +08:00
DraftStore._draftSessions = {
abc: {
changes:
{commit: () => Promise.resolve()},
draft() {
return {pristine: false};
},
},
2016-05-07 05:10:28 +08:00
};
});
it("should still wait one tick before firing NylasEnv.close again", () => {
const callback = jasmine.createSpy('callback');
expect(DraftStore._onBeforeUnload(callback)).toBe(false);
expect(callback).not.toHaveBeenCalled();
advanceClock();
advanceClock(1000);
expect(callback).toHaveBeenCalled();
});
});
describe("when there are no drafts", () => {
beforeEach(() => {
DraftStore._draftSessions = {};
});
it("should return true and allow the window to close", () => {
expect(DraftStore._onBeforeUnload()).toBe(true);
});
});
});
describe("sending a draft", () => {
beforeEach(() => {
this.draft = new Message({
clientId: "local-123",
threadId: "thread-123",
replyToMessageId: "message-123",
uploads: ['stub'],
});
DraftStore._draftSessions = {};
DraftStore._draftsSending = {};
this.forceCommit = false;
const session = {
prepare() {
return Promise.resolve(session);
},
teardown() {},
draft: () => this.draft,
changes: {
commit: ({force} = {}) => {
this.forceCommit = force;
return Promise.resolve();
},
},
};
DraftStore._draftSessions[this.draft.clientId] = session;
spyOn(DraftStore, "_doneWithSession").andCallThrough();
spyOn(DraftHelpers, "prepareDraftForSyncback").andReturn(Promise.resolve());
spyOn(DraftStore, "trigger");
spyOn(SoundRegistry, "playSound");
spyOn(Actions, "queueTask");
});
it("plays a sound immediately when sending draft", () => {
spyOn(NylasEnv.config, "get").andReturn(true);
DraftStore._onSendDraft(this.draft.clientId);
advanceClock();
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds");
expect(SoundRegistry.playSound).toHaveBeenCalledWith("hit-send");
});
it("doesn't plays a sound if the setting is off", () => {
spyOn(NylasEnv.config, "get").andReturn(false);
DraftStore._onSendDraft(this.draft.clientId);
advanceClock();
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds");
expect(SoundRegistry.playSound).not.toHaveBeenCalled();
});
it("sets the sending state when sending", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
DraftStore._onSendDraft(this.draft.clientId);
advanceClock();
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(true);
});
// Since all changes haven't been applied yet, we want to ensure that
// no view of the draft renders the draft as if its sending, but with
// the wrong text.
it("does NOT trigger until the latest changes have been applied", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
runs(() => {
DraftStore._onSendDraft(this.draft.clientId);
expect(DraftStore.trigger).not.toHaveBeenCalled();
});
waitsFor(() => {
return Actions.queueTask.calls.length > 0;
});
runs(() => {
// Normally, the session.changes.commit will persist to the
// Database. Since that's stubbed out, we need to manually invoke
// to database update event to get the trigger (which we want to
// test) to fire
DraftStore._onDataChanged({
objectClass: "Message",
objects: [{draft: true}],
});
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(true);
expect(DraftStore.trigger).toHaveBeenCalled();
expect(DraftStore.trigger.calls.length).toBe(1);
});
});
it("returns false if the draft hasn't been seen", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
});
it("closes the window if it's a popout", () => {
spyOn(NylasEnv, "getWindowType").andReturn("composer");
spyOn(NylasEnv, "isMainWindow").andReturn(false);
spyOn(NylasEnv, "close");
runs(() => {
return DraftStore._onSendDraft(this.draft.clientId);
});
waitsFor("N1 to close", () => NylasEnv.close.calls.length > 0);
});
it("doesn't close the window if it's inline", () => {
spyOn(NylasEnv, "getWindowType").andReturn("other");
spyOn(NylasEnv, "isMainWindow").andReturn(false);
spyOn(NylasEnv, "close");
spyOn(DraftStore, "_isPopout").andCallThrough();
runs(() => {
DraftStore._onSendDraft(this.draft.clientId);
});
waitsFor(() => DraftStore._isPopout.calls.length > 0);
runs(() => {
expect(NylasEnv.close).not.toHaveBeenCalled();
});
});
it("resets the sending state if there's an error", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(false);
DraftStore._draftsSending[this.draft.clientId] = true;
Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
});
it("displays a popup in the main window if there's an error", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
spyOn(remote.dialog, "showMessageBox");
spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true;
Actions.sendDraftFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(400);
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
expect(remote.dialog.showMessageBox).toHaveBeenCalled();
const dialogArgs = remote.dialog.showMessageBox.mostRecentCall.args[1];
expect(dialogArgs.detail).toEqual("boohoo");
expect(Actions.composePopoutDraft).not.toHaveBeenCalled();
});
it("re-opens the draft if you're not looking at the thread", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true;
Actions.sendDraftFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(400);
expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0];
expect(call.args[0]).toBe(this.draft.clientId);
expect(call.args[1]).toEqual({errorMessage: "boohoo"});
});
it("re-opens the draft if there is no thread id", () => {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
spyOn(Actions, "composePopoutDraft");
DraftStore._draftsSending[this.draft.clientId] = true;
spyOn(FocusedContentStore, "focused").andReturn(null);
Actions.sendDraftFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
advanceClock(400);
expect(Actions.composePopoutDraft).toHaveBeenCalled();
const call = Actions.composePopoutDraft.calls[0];
expect(call.args[0]).toBe(this.draft.clientId);
expect(call.args[1]).toEqual({errorMessage: "boohoo"});
});
});
describe("session teardown", () => {
beforeEach(() => {
spyOn(NylasEnv, 'isMainWindow').andReturn(true);
this.draftTeardown = jasmine.createSpy('draft teardown');
this.session = {
draftClientId: "abc",
draft() {
return {pristine: false};
},
changes: {
commit() { return Promise.resolve(); },
reset() {},
},
teardown: this.draftTeardown,
};
2016-05-07 05:10:28 +08:00
DraftStore._draftSessions = {abc: this.session};
DraftStore._doneWithSession(this.session);
});
it("removes from the list of draftSessions", () => {
expect(DraftStore._draftSessions.abc).toBeUndefined();
});
it("Calls teardown on the session", () => {
expect(this.draftTeardown).toHaveBeenCalled();
});
});
describe("mailto handling", () => {
beforeEach(() => {
spyOn(NylasEnv, 'isMainWindow').andReturn(true);
});
describe("extensions", () => {
beforeEach(() => {
ExtensionRegistry.Composer.register(TestExtension);
});
afterEach(() => {
ExtensionRegistry.Composer.unregister(TestExtension);
});
it("should give extensions a chance to customize the draft via ext.prepareNewDraft", () => {
waitsForPromise(() => {
return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => {
const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0];
expect(received.body.indexOf("Edited by TestExtension!")).toBe(0);
});
});
});
});
it("should call through to DraftFactory and popout a new draft", () => {
const draft = new Message({clientId: "A", body: '123'});
spyOn(DraftFactory, 'createDraftForMailto').andReturn(Promise.resolve(draft));
spyOn(DraftStore, '_onPopoutDraftClientId');
waitsForPromise(() => {
return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => {
const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0];
expect(received).toEqual(draft);
expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled();
});
});
});
});
describe("mailfiles handling", () => {
it("should popout a new draft", () => {
const defaultMe = new Contact();
spyOn(DraftStore, '_onPopoutDraftClientId');
spyOn(Account.prototype, 'defaultMe').andReturn(defaultMe);
spyOn(Actions, 'addAttachment').andCallFake(({callback}) => callback());
DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']);
waitsFor(() => DatabaseTransaction.prototype.persistModel.callCount > 0);
runs(() => {
const {body, subject, from} = DatabaseTransaction.prototype.persistModel.calls[0].args[0];
expect({body, subject, from}).toEqual({body: '', subject: '', from: [defaultMe]});
expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled();
});
});
it("should call addAttachment for each provided file path", () => {
spyOn(Actions, 'addAttachment');
DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']);
waitsFor(() => Actions.addAttachment.callCount === 2);
runs(() => {
expect(Actions.addAttachment.calls[0].args[0].filePath).toEqual('/Users/ben/file1.png');
expect(Actions.addAttachment.calls[1].args[0].filePath).toEqual('/Users/ben/file2.png');
});
});
});
});