diff --git a/src/routes/_components/compose/ComposeAutosuggest.html b/src/routes/_components/compose/ComposeAutosuggest.html index f07c846a..45254f1f 100644 --- a/src/routes/_components/compose/ComposeAutosuggest.html +++ b/src/routes/_components/compose/ComposeAutosuggest.html @@ -13,7 +13,7 @@ import { throttleTimer } from '../../_utils/throttleTimer' import { on } from '../../_utils/eventBus' import { store } from '../../_store/store' - import { getScrollContainer } from '../../_utils/scrollContainer' + import { addScrollListener, getScrollContainer, removeScrollListener } from '../../_utils/scrollContainer' import { get } from '../../_utils/lodash-lite' import { registerResizeListener, unregisterResizeListener } from '../../_utils/resize' @@ -46,7 +46,7 @@ this._element.remove() } unregisterResizeListener(this.onResize) - document.removeEventListener('scroll', this.onResize) + removeScrollListener(this.onResize) }, methods: { observe, @@ -82,7 +82,7 @@ } }) registerResizeListener(this.onResize) - document.addEventListener('scroll', this.onResize) + addScrollListener(this.onResize) }) }) }, diff --git a/tests/spec/038-memory-leaks.js b/tests/spec/038-memory-leaks.js index 81b56326..6e750ec8 100644 --- a/tests/spec/038-memory-leaks.js +++ b/tests/spec/038-memory-leaks.js @@ -1,25 +1,49 @@ import { + accountProfileName, + closeDialogButton, composeInput, getNthAutosuggestionResult, + getNthStatusMediaButton, getNthStatusSensitiveMediaButton, + getNthStatus, getNumSyntheticListeners, getUrl, - homeNavButton, + homeNavButton, modalDialog, notificationsNavButton, scrollToStatus, scrollToTop, settingsNavButton, sleep } from '../utils' import { loginAsFoobar } from '../roles' +import { installDomListenerListener, getNumDomListeners } from '../spyDomListeners' +import { homeTimeline } from '../fixtures' +import { Selector as $ } from 'testcafe' fixture`038-memory-leaks.js` .page`http://localhost:4002` -async function goToStartPoint (t) { +async function runMemoryLeakTest (t, firstStep, secondStep) { + await loginAsFoobar(t) + await installDomListenerListener() + await firstStep() + await sleep(1000) + const numSyntheticListeners = await getNumSyntheticListeners() + const numDomListeners = await getNumDomListeners() + await t + .expect(numSyntheticListeners).typeOf('number') + .expect(numDomListeners).typeOf('number') + await secondStep() + await sleep(1000) + await t + .expect(getNumSyntheticListeners()).eql(numSyntheticListeners) + .expect(getNumDomListeners()).eql(numDomListeners) +} + +async function goToSettings (t) { await t .click(settingsNavButton) .expect(getUrl()).contains('/settings') } -async function interactAndGoToEndPoint (t) { +async function scrollUpAndDownAndDoAutosuggest (t) { await t .click(homeNavButton) .expect(getUrl()).eql('http://localhost:4002/') @@ -28,19 +52,57 @@ async function interactAndGoToEndPoint (t) { await t .typeText(composeInput, 'hey @qu') .expect(getNthAutosuggestionResult(1).find('.sr-only').innerText).contains('@quux') - .click(settingsNavButton) - .expect(getUrl()).contains('/settings') + await goToSettings(t) } -test('Does not leak synthetic listeners', async t => { - await loginAsFoobar(t) - await goToStartPoint(t) - await sleep(1000) - const numSyntheticListeners = await getNumSyntheticListeners() +async function openAndCloseMediaModal (t) { await t - .expect(numSyntheticListeners).typeOf('number') - await interactAndGoToEndPoint(t) - await sleep(1000) + .click(homeNavButton) + .expect(getUrl()).eql('http://localhost:4002/') + const idx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW') + await scrollToStatus(t, idx + 1) await t - .expect(getNumSyntheticListeners()).eql(numSyntheticListeners) + .click(getNthStatusSensitiveMediaButton(idx + 1)) + .click(getNthStatusMediaButton(idx + 1)) + .expect(modalDialog.hasAttribute('aria-hidden')).notOk() + .click(closeDialogButton) + .expect(modalDialog.exists).notOk() + await goToSettings(t) +} + +async function openAProfileAndNotifications (t) { + await t + .click(homeNavButton) + .expect(getUrl()).eql('http://localhost:4002/') + .hover(getNthStatus(1)) + .click($('.status-author-name').withText(('quux'))) + .expect(getUrl()).contains('/accounts/3') + .expect(accountProfileName.innerText).contains('quux') + .click(notificationsNavButton) + .hover(getNthStatus(1)) + await goToSettings(t) +} + +test('Does not leak listeners in timeline or autosuggest', async t => { + await runMemoryLeakTest( + t, + () => goToSettings(t), + () => scrollUpAndDownAndDoAutosuggest(t) + ) +}) + +test('Does not leak listeners in modal', async t => { + await runMemoryLeakTest( + t, + () => goToSettings(t), + () => openAndCloseMediaModal(t) + ) +}) + +test('Does not leak listeners in account profile or notifications page', async t => { + await runMemoryLeakTest( + t, + () => goToSettings(t), + () => openAProfileAndNotifications(t) + ) }) diff --git a/tests/spyDomListeners.js b/tests/spyDomListeners.js new file mode 100644 index 00000000..69023161 --- /dev/null +++ b/tests/spyDomListeners.js @@ -0,0 +1,87 @@ +import { ClientFunction as exec } from 'testcafe' + +export const installDomListenerListener = exec(() => { + function eql (a, b) { + const aType = typeof a + const bType = typeof b + switch (aType) { + case 'boolean': + return bType === 'boolean' && b === a + case 'undefined': + return bType === 'undefined' + case 'object': + if (a === null) { + return b === null + } + if (b === null) { + return false + } + if (Object.keys(a).length !== Object.keys(b).length) { + return false + } + for (const key of Object.keys(a)) { + if (a[key] !== b[key]) { + return false + } + } + return true + } + return false + } + + function spyAddListener (proto) { + const addEventListener = proto.addEventListener + proto.addEventListener = function (type, listener, options) { + if (!this.__listeners) { + this.__listeners = {} + } + if (!this.__listeners[type]) { + this.__listeners[type] = [] + } + this.__listeners[type].push({ listener, options }) + return addEventListener.apply(this, arguments) + } + } + + function spyRemoveListener (proto) { + const removeEventListener = proto.removeEventListener + proto.removeEventListener = function (type, listener, options) { + if (this.__listeners && this.__listeners[type]) { + const arr = this.__listeners[type] + for (let i = arr.length - 1; i >= 0; i--) { + const { listener: otherListener, options: otherOptions } = arr[i] + if (listener === otherListener && eql(options, otherOptions)) { + arr.splice(i, 1) + } + } + } + return removeEventListener.apply(this, arguments) + } + } + + function spy (proto) { + spyAddListener(proto) + spyRemoveListener(proto) + } + + spy(Element.prototype) + spy(document) + spy(window) +}) + +export const getNumDomListeners = exec(() => { + function getNumListeners (obj) { + let sum = 0 + if (obj.__listeners) { + for (const key of Object.keys(obj.__listeners)) { + sum += obj.__listeners[key].length + } + } + return sum + } + + return [...document.querySelectorAll('*')] + .concat([window, document]) + .map(getNumListeners) + .reduce((a, b) => a + b, 0) +})