test: test for DOM listener memory leaks (#1654)

* test: test for DOM listener memory leaks

* fix whitespace change, unintended
This commit is contained in:
Nolan Lawson 2019-11-23 23:42:22 -08:00 committed by GitHub
parent 95ef639b21
commit cbbf5abd7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 166 additions and 17 deletions

View file

@ -13,7 +13,7 @@
import { throttleTimer } from '../../_utils/throttleTimer' import { throttleTimer } from '../../_utils/throttleTimer'
import { on } from '../../_utils/eventBus' import { on } from '../../_utils/eventBus'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { getScrollContainer } from '../../_utils/scrollContainer' import { addScrollListener, getScrollContainer, removeScrollListener } from '../../_utils/scrollContainer'
import { get } from '../../_utils/lodash-lite' import { get } from '../../_utils/lodash-lite'
import { registerResizeListener, unregisterResizeListener } from '../../_utils/resize' import { registerResizeListener, unregisterResizeListener } from '../../_utils/resize'
@ -46,7 +46,7 @@
this._element.remove() this._element.remove()
} }
unregisterResizeListener(this.onResize) unregisterResizeListener(this.onResize)
document.removeEventListener('scroll', this.onResize) removeScrollListener(this.onResize)
}, },
methods: { methods: {
observe, observe,
@ -82,7 +82,7 @@
} }
}) })
registerResizeListener(this.onResize) registerResizeListener(this.onResize)
document.addEventListener('scroll', this.onResize) addScrollListener(this.onResize)
}) })
}) })
}, },

View file

@ -1,25 +1,49 @@
import { import {
accountProfileName,
closeDialogButton,
composeInput, composeInput,
getNthAutosuggestionResult, getNthAutosuggestionResult,
getNthStatusMediaButton, getNthStatusSensitiveMediaButton,
getNthStatus,
getNumSyntheticListeners, getNumSyntheticListeners,
getUrl, getUrl,
homeNavButton, homeNavButton, modalDialog, notificationsNavButton,
scrollToStatus, scrollToStatus,
scrollToTop, scrollToTop,
settingsNavButton, sleep settingsNavButton, sleep
} from '../utils' } from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
import { installDomListenerListener, getNumDomListeners } from '../spyDomListeners'
import { homeTimeline } from '../fixtures'
import { Selector as $ } from 'testcafe'
fixture`038-memory-leaks.js` fixture`038-memory-leaks.js`
.page`http://localhost:4002` .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 await t
.click(settingsNavButton) .click(settingsNavButton)
.expect(getUrl()).contains('/settings') .expect(getUrl()).contains('/settings')
} }
async function interactAndGoToEndPoint (t) { async function scrollUpAndDownAndDoAutosuggest (t) {
await t await t
.click(homeNavButton) .click(homeNavButton)
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
@ -28,19 +52,57 @@ async function interactAndGoToEndPoint (t) {
await t await t
.typeText(composeInput, 'hey @qu') .typeText(composeInput, 'hey @qu')
.expect(getNthAutosuggestionResult(1).find('.sr-only').innerText).contains('@quux') .expect(getNthAutosuggestionResult(1).find('.sr-only').innerText).contains('@quux')
.click(settingsNavButton) await goToSettings(t)
.expect(getUrl()).contains('/settings')
} }
test('Does not leak synthetic listeners', async t => { async function openAndCloseMediaModal (t) {
await loginAsFoobar(t)
await goToStartPoint(t)
await sleep(1000)
const numSyntheticListeners = await getNumSyntheticListeners()
await t await t
.expect(numSyntheticListeners).typeOf('number') .click(homeNavButton)
await interactAndGoToEndPoint(t) .expect(getUrl()).eql('http://localhost:4002/')
await sleep(1000) const idx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
await scrollToStatus(t, idx + 1)
await t 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)
)
}) })

87
tests/spyDomListeners.js Normal file
View file

@ -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)
})