Make JavaScript tests fail for unhandled Promise rejections

This commit is contained in:
Winnie Hellmann 2017-06-23 09:28:19 +00:00 committed by Phil Hughes
parent 9c7bf12356
commit 925eea2672
8 changed files with 254 additions and 157 deletions

View file

@ -17,7 +17,7 @@ export default {
methods: {
submit(e) {
e.preventDefault();
if (this.title.trim() === '') return;
if (this.title.trim() === '') return Promise.resolve();
this.error = false;
@ -29,7 +29,10 @@ export default {
assignees: [],
});
this.list.newIssue(issue)
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
return this.list.newIssue(issue)
.then(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
@ -47,9 +50,6 @@ export default {
// Show error message
this.error = true;
});
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
},
cancel() {
this.title = '';

View file

@ -112,8 +112,7 @@ class List {
.then((resp) => {
const data = resp.json();
issue.id = data.iid;
})
.then(() => {
if (this.issuesSize > 1) {
const moveBeforeIid = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);

View file

@ -40,6 +40,10 @@ class FilteredSearchManager {
return [];
})
.then((searches) => {
if (!searches) {
return;
}
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(

View file

@ -12,6 +12,7 @@ import './mock_data';
describe('Issue boards new issue form', () => {
let vm;
let list;
let newIssueMock;
const promiseReturn = {
json() {
return {
@ -21,7 +22,11 @@ describe('Issue boards new issue form', () => {
};
const submitIssue = () => {
vm.$el.querySelector('.btn-success').click();
const dummySubmitEvent = {
preventDefault() {},
};
vm.$refs.submitButton = vm.$el.querySelector('.btn-success');
return vm.submit(dummySubmitEvent);
};
beforeEach((done) => {
@ -32,29 +37,35 @@ describe('Issue boards new issue form', () => {
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
setTimeout(() => {
list = new List(listObj);
list = new List(listObj);
spyOn(gl.boardService, 'newIssue').and.callFake(() => new Promise((resolve, reject) => {
if (vm.title === 'error') {
reject();
} else {
resolve(promiseReturn);
}
}));
newIssueMock = Promise.resolve(promiseReturn);
spyOn(list, 'newIssue').and.callFake(() => newIssueMock);
vm = new BoardNewIssueComp({
propsData: {
list,
},
}).$mount();
vm = new BoardNewIssueComp({
propsData: {
list,
},
}).$mount();
done();
}, 0);
Vue.nextTick()
.then(done)
.catch(done.fail);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
it('calls submit if submit button is clicked', (done) => {
spyOn(vm, 'submit');
vm.title = 'Testing Title';
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.btn-success').click();
expect(vm.submit.calls.count()).toBe(1);
expect(vm.$refs['submit-button']).toBe(vm.$el.querySelector('.btn-success'));
})
.then(done)
.catch(done.fail);
});
it('disables submit button if title is empty', () => {
@ -64,136 +75,122 @@ describe('Issue boards new issue form', () => {
it('enables submit button if title is not empty', (done) => {
vm.title = 'Testing Title';
setTimeout(() => {
expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
done();
}, 0);
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title');
expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true);
})
.then(done)
.catch(done.fail);
});
it('clears title after clicking cancel', (done) => {
vm.$el.querySelector('.btn-default').click();
setTimeout(() => {
expect(vm.title).toBe('');
done();
}, 0);
Vue.nextTick()
.then(() => {
expect(vm.title).toBe('');
})
.then(done)
.catch(done.fail);
});
it('does not create new issue if title is empty', (done) => {
submitIssue();
setTimeout(() => {
expect(gl.boardService.newIssue).not.toHaveBeenCalled();
done();
}, 0);
submitIssue()
.then(() => {
expect(list.newIssue).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
describe('submit success', () => {
it('creates new issue', (done) => {
vm.title = 'submit title';
setTimeout(() => {
submitIssue();
expect(gl.boardService.newIssue).toHaveBeenCalled();
done();
}, 0);
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(list.newIssue).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('enables button after submit', (done) => {
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
done();
}, 0);
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(vm.$el.querySelector('.btn-success').disabled).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('clears title after submit', (done) => {
vm.title = 'submit issue';
Vue.nextTick(() => {
submitIssue();
setTimeout(() => {
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(vm.title).toBe('');
done();
}, 0);
});
});
it('adds new issue to top of list after submit request', (done) => {
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
setTimeout(() => {
expect(list.issues.length).toBe(2);
expect(list.issues[0].title).toBe('submit issue');
expect(list.issues[0].subscribed).toBe(true);
done();
}, 0);
}, 0);
})
.then(done)
.catch(done.fail);
});
it('sets detail issue after submit', (done) => {
expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined);
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
setTimeout(() => {
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue');
done();
}, 0);
}, 0);
})
.then(done)
.catch(done.fail);
});
it('sets detail list after submit', (done) => {
vm.title = 'submit issue';
setTimeout(() => {
submitIssue();
setTimeout(() => {
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id);
done();
}, 0);
}, 0);
})
.then(done)
.catch(done.fail);
});
});
describe('submit error', () => {
it('removes issue', (done) => {
beforeEach(() => {
newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
vm.title = 'error';
});
setTimeout(() => {
submitIssue();
setTimeout(() => {
it('removes issue', (done) => {
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(list.issues.length).toBe(1);
done();
}, 0);
}, 0);
})
.then(done)
.catch(done.fail);
});
it('shows error', (done) => {
vm.title = 'error';
setTimeout(() => {
submitIssue();
setTimeout(() => {
Vue.nextTick()
.then(submitIssue)
.then(() => {
expect(vm.error).toBe(true);
done();
}, 0);
}, 0);
})
.then(done)
.catch(done.fail);
});
});
});

View file

@ -150,4 +150,41 @@ describe('List model', () => {
expect(list.getIssues).toHaveBeenCalled();
});
});
describe('newIssue', () => {
beforeEach(() => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
json() {
return {
iid: 42,
};
},
}));
});
it('adds new issue to top of list', (done) => {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
}));
const dummyIssue = new ListIssue({
title: 'new issue',
iid: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
});
list.newIssue(dummyIssue)
.then(() => {
expect(list.issues.length).toBe(2);
expect(list.issues[0]).toBe(dummyIssue);
})
.then(done)
.catch(done.fail);
});
});
});

View file

@ -48,18 +48,23 @@ describe('Filtered Search Manager', () => {
</div>
`);
spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
});
const initializeManager = () => {
/* eslint-disable jasmine/no-unsafe-spy */
spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
/* eslint-enable jasmine/no-unsafe-spy */
input = document.querySelector('.filtered-search');
tokensContainer = document.querySelector('.tokens-container');
manager = new gl.FilteredSearchManager();
manager.setup();
});
};
afterEach(() => {
manager.cleanup();
@ -67,33 +72,34 @@ describe('Filtered Search Manager', () => {
describe('class constructor', () => {
const isLocalStorageAvailable = 'isLocalStorageAvailable';
let filteredSearchManager;
beforeEach(() => {
spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable);
spyOn(recentSearchesStoreSrc, 'default');
spyOn(RecentSearchesRoot.prototype, 'render');
filteredSearchManager = new gl.FilteredSearchManager();
filteredSearchManager.setup();
return filteredSearchManager;
});
it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
manager = new gl.FilteredSearchManager();
expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({
isLocalStorageAvailable,
allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
});
});
});
describe('setup', () => {
beforeEach(() => {
manager = new gl.FilteredSearchManager();
});
it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError()));
spyOn(window, 'Flash');
filteredSearchManager = new gl.FilteredSearchManager();
filteredSearchManager.setup();
manager.setup();
expect(window.Flash).not.toHaveBeenCalled();
});
@ -102,6 +108,7 @@ describe('Filtered Search Manager', () => {
describe('searchState', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {});
initializeManager();
});
it('should blur button', () => {
@ -148,6 +155,10 @@ describe('Filtered Search Manager', () => {
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
beforeEach(() => {
initializeManager();
});
it('should search with a single word', (done) => {
input.value = 'searchTerm';
@ -197,6 +208,10 @@ describe('Filtered Search Manager', () => {
});
describe('handleInputPlaceholder', () => {
beforeEach(() => {
initializeManager();
});
it('should render placeholder when there is no input', () => {
expect(input.placeholder).toEqual(placeholder);
});
@ -223,6 +238,10 @@ describe('Filtered Search Manager', () => {
});
describe('checkForBackspace', () => {
beforeEach(() => {
initializeManager();
});
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
@ -260,6 +279,10 @@ describe('Filtered Search Manager', () => {
});
describe('removeToken', () => {
beforeEach(() => {
initializeManager();
});
it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
@ -291,6 +314,7 @@ describe('Filtered Search Manager', () => {
describe('removeSelectedTokenKeydown', () => {
beforeEach(() => {
initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
);
@ -344,27 +368,39 @@ describe('Filtered Search Manager', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
spyOn(gl.FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
manager.removeSelectedToken();
initializeManager();
});
it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
manager.removeSelectedToken();
expect(gl.FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
});
it('calls handleInputPlaceholder', () => {
manager.removeSelectedToken();
expect(manager.handleInputPlaceholder).toHaveBeenCalled();
});
it('calls toggleClearSearchButton', () => {
manager.removeSelectedToken();
expect(manager.toggleClearSearchButton).toHaveBeenCalled();
});
it('calls update dropdown offset', () => {
manager.removeSelectedToken();
expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
});
});
describe('toggleInputContainerFocus', () => {
beforeEach(() => {
initializeManager();
});
it('toggles on focus', () => {
input.focus();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);

View file

@ -3,17 +3,9 @@ import '~/render_math';
import '~/render_gfm';
import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import Poll from '~/lib/utils/poll';
import issueShowData from '../mock_data';
const issueShowInterceptor = data => (request, next) => {
next(request.respondWith(JSON.stringify(data), {
status: 200,
headers: {
'POLL-INTERVAL': 1,
},
}));
};
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
@ -24,10 +16,10 @@ describe('Issuable output', () => {
let vm;
beforeEach(() => {
const IssuableDescriptionComponent = Vue.extend(issuableApp);
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
spyOn(eventHub, '$emit');
spyOn(Poll.prototype, 'makeRequest');
const IssuableDescriptionComponent = Vue.extend(issuableApp);
vm = new IssuableDescriptionComponent({
propsData: {
@ -54,9 +46,18 @@ describe('Issuable output', () => {
});
it('should render a title/description/edited and update title/description/edited on update', (done) => {
setTimeout(() => {
const editedText = vm.$el.querySelector('.edited-text');
vm.poll.options.successCallback({
json() {
return issueShowData.initialRequest;
},
});
let editedText;
Vue.nextTick()
.then(() => {
editedText = vm.$el.querySelector('.edited-text');
})
.then(() => {
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
@ -64,22 +65,27 @@ describe('Issuable output', () => {
expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
setTimeout(() => {
expect(document.querySelector('title').innerText).toContain('2 (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
done();
})
.then(() => {
vm.poll.options.successCallback({
json() {
return issueShowData.secondRequest;
},
});
});
})
.then(Vue.nextTick)
.then(() => {
expect(document.querySelector('title').innerText).toContain('2 (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
expect(editedText.querySelector('time')).toBeTruthy();
})
.then(done)
.catch(done.fail);
});
it('shows actions if permissions are correct', (done) => {
@ -344,21 +350,23 @@ describe('Issuable output', () => {
describe('open form', () => {
it('shows locked warning if form is open & data is different', (done) => {
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
vm.poll.options.successCallback({
json() {
return issueShowData.initialRequest;
},
});
Vue.nextTick()
.then(() => new Promise((resolve) => {
setTimeout(resolve);
}))
.then(() => {
vm.openForm();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
return new Promise((resolve) => {
setTimeout(resolve);
vm.poll.options.successCallback({
json() {
return issueShowData.secondRequest;
},
});
})
.then(Vue.nextTick)
.then(() => {
expect(
vm.formState.lockedWarningVisible,
@ -367,9 +375,8 @@ describe('Issuable output', () => {
expect(
vm.$el.querySelector('.alert'),
).not.toBeNull();
done();
})
.then(done)
.catch(done.fail);
});
});

View file

@ -22,6 +22,19 @@ window.gl = window.gl || {};
window.gl.TEST_HOST = 'http://test.host';
window.gon = window.gon || {};
let hasUnhandledPromiseRejections = false;
window.addEventListener('unhandledrejection', (event) => {
hasUnhandledPromiseRejections = true;
console.error('Unhandled promise rejection:');
console.error(event.reason.stack || event.reason);
});
const checkUnhandledPromiseRejections = (done) => {
expect(hasUnhandledPromiseRejections).toBe(false);
done();
};
// HACK: Chrome 59 disconnects if there are too many synchronous tests in a row
// because it appears to lock up the thread that communicates to Karma's socket
// This async beforeEach gets called on every spec and releases the JS thread long
@ -63,6 +76,10 @@ testsContext.keys().forEach(function (path) {
}
});
it('has no unhandled Promise rejections', (done) => {
setTimeout(checkUnhandledPromiseRejections(done), 1000);
});
// if we're generating coverage reports, make sure to include all files so
// that we can catch files with 0% coverage
// see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15