window.CapybaraLockstep = (function() {
// State and configuration let debug let jobCount let idleCallbacks let waitTasks reset() function reset() { jobCount = 0 idleCallbacks = [] waitTasks = 0 debug = false } function isIdle() { // Can't check for document.readyState or body.initializing here, // since the user might navigate away from the page before it finishes // initializing. return jobCount === 0 } function isBusy() { return !isIdle() } function log(...args) { if (debug) { args[0] = '%c[capybara-lockstep] ' + args[0] args.splice(1, 0, 'color: #666666') console.log.apply(console, args) } } function logPositive(...args) { args[0] = '%c' + args[0] log(args[0], 'color: #117722', ...args.slice(1)) } function logNegative(...args) { args[0] = '%c' + args[0] log(args[0], 'color: #cc3311', ...args.slice(1)) } function startWork(tag) { jobCount++ if (tag) { logNegative('Started work: %s [%d jobs]', tag, jobCount) } } function startWorkUntil(promise, tag) { startWork(tag) let taggedStopWork = stopWork.bind(this, tag) promise.then(taggedStopWork, taggedStopWork) } function stopWork(tag) { let tasksElapsed = 0 let check = function() { if (tasksElapsed < waitTasks) { tasksElapsed++ setTimeout(check) } else { stopWorkNow(tag) } } check() } function stopWorkNow(tag) { jobCount-- if (tag) { logPositive('Finished work: %s [%d jobs]', tag, jobCount) } let idleCallback while (isIdle() && (idleCallback = idleCallbacks.shift())) { idleCallback('Finished waiting for browser') } } function trackFetch() { if (!window.fetch) { return } let oldFetch = window.fetch window.fetch = function() { let promise = oldFetch.apply(this, arguments) startWorkUntil(promise, 'fetch ' + arguments[0]) return promise } } function trackXHR() { let oldOpen = XMLHttpRequest.prototype.open let oldSend = XMLHttpRequest.prototype.send XMLHttpRequest.prototype.open = function() { this.capybaraLockstepURL = arguments[1] return oldOpen.apply(this, arguments) } XMLHttpRequest.prototype.send = function() { let workTag = 'XHR to '+ this.capybaraLockstepURL startWork(workTag) try { this.addEventListener('readystatechange', function(event) { if (this.readyState === 4) { stopWork(workTag) } }.bind(this)) return oldSend.apply(this, arguments) } catch (e) { // If we get a sync exception during request dispatch // we assume the request never went out. stopWork(workTag) throw e } } } function trackRemoteElements() { if (!window.MutationObserver) { return } // Dynamic imports or analytics snippets may insert a script element // that loads and executes additional JavaScript. We want to be isBusy() // until such scripts have loaded or errored. let observer = new MutationObserver(onAnyElementChanged) observer.observe(document, { subtree: true, childList: true }) } function trackJQuery() { // CapybaraLockstep.track() is called as the first script in the head. // jQuery will be loaded after us, so we wait until DOMContentReady. whenReady(function() { if (!window.jQuery || waitTasks > 0) { return } // Although $.ajax() uses XHR internally, it also uses $.Deferred() which does // not resolve in the next microtask but in the next *task* (it makes itself // async using setTimoeut()). Hence we need to wait for it in addition to XHR. // // If user code also uses $.Deferred(), it is also recommended to set // CapybaraLockdown.waitTasks = 1 or higher. let oldAjax = window.jQuery.ajax window.jQuery.ajax = function() { let promise = oldAjax.apply(this, arguments) startWorkUntil(promise) return promise } }) } function isRemoteScript(element) { if (element.tagName === 'SCRIPT') { let src = element.getAttribute('src') let type = element.getAttribute('type') return src && (!type || /javascript/i.test(type)) } } function isRemoteImage(element) { if (element.tagName === 'IMG' && !element.complete) { let src = element.getAttribute('src') let srcSet = element.getAttribute('srcset') let localSrcPattern = /^data:/ let localSrcSetPattern = /(^|\s)data:/ let hasLocalSrc = src && localSrcPattern.test(src) let hasLocalSrcSet = srcSet && localSrcSetPattern.test(srcSet) return (src && !hasLocalSrc) || (srcSet && !hasLocalSrcSet) } } function isRemoteInlineFrame(element) { if (element.tagName === 'IFRAME') { let src = element.getAttribute('src') let localSrcPattern = /^data:/ let hasLocalSrc = src && localSrcPattern.test(src) return (src && !hasLocalSrc) } } function trackRemoteElement(element, condition, workTag) { if (!condition(element)) { return } let stopped = false startWork(workTag) let doStop = function() { stopped = true element.removeEventListener('load', doStop) element.removeEventListener('error', doStop) stopWork(workTag) } let checkCondition = function() { if (stopped) { // A `load` or `error` event has fired. // We can stop here. No need to schedule another check. return } else if (isDetached(element) || !condition(element)) { // If it is detached or if its `[src]` attribute changes to a data: URL // we may never get a `load` or `error` event. doStop() } else { scheduleCheckCondition() } } let scheduleCheckCondition = function() { setTimeout(checkCondition, 200) } element.addEventListener('load', doStop) element.addEventListener('error', doStop) // We periodically check whether we still think the element will // produce a `load` or `error` event. scheduleCheckCondition() } function onAnyElementChanged(changes) { changes.forEach(function(change) { change.addedNodes.forEach(function(addedNode) { if (addedNode.nodeType === Node.ELEMENT_NODE) { trackRemoteElement(addedNode, isRemoteScript, 'Script') trackRemoteElement(addedNode, isRemoteImage, 'Image') trackRemoteElement(addedNode, isRemoteInlineFrame, 'Inline frame') } }) }) } function isDetached(element) { return !document.contains(element) } function whenReady(callback) { // Values are "loading", "interactive" and "completed". // https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState if (document.readyState !== 'loading') { callback() } else { document.addEventListener('DOMContentLoaded', callback) } } function trackOldUnpoly() { // CapybaraLockstep.track() is called as the first script in the head. // Unpoly will be loaded after us, so we wait until DOMContentReady. whenReady(function() { // Unpoly 0.x would wait one task after DOMContentLoaded before booting. // There's a slim chance that Capybara can observe the page before compilers have run. // Unpoly 1.0+ runs compilers on DOMContentLoaded, so there's no issue. if (window.up?.version?.startsWith('0.')) { startWork('Old Unpoly') setTimeout(function () { stopWork('Old Unpoly') }) } }) } function track() { trackOldUnpoly() trackFetch() trackXHR() trackRemoteElements() trackJQuery() } function synchronize(callback) { if (isIdle()) { callback() } else { idleCallbacks.push(callback) } } return { track: track, isBusy: isBusy, isIdle: isIdle, startWork: startWork, stopWork: stopWork, synchronize: synchronize, reset: reset, set debug(value) { debug = value }, set waitTasks(value) { waitTasks = value } }
})()
CapybaraLockstep.track()