diff --git a/lib/capybara-lockstep/configuration.rb b/lib/capybara-lockstep/configuration.rb index 6476748..16c8a78 100644 --- a/lib/capybara-lockstep/configuration.rb +++ b/lib/capybara-lockstep/configuration.rb @@ -89,6 +89,20 @@ def wait_tasks=(value) @wait_tasks end + def wait_timeout_max_delay + @wait_timeout_max_delay + end + + def wait_timeout_max_delay=(value) + @wait_timeout_max_delay = value + + send_config_to_browser(<<~JS) + CapybaraLockstep.waitTimeoutMaxDelay = #{value.to_json} + JS + + @wait_timeout_max_delay + end + def after_synchronize(&callback) after_synchronize_callbacks << callback end diff --git a/lib/capybara-lockstep/helper.js b/lib/capybara-lockstep/helper.js index 7cd4179..b1b73ff 100644 --- a/lib/capybara-lockstep/helper.js +++ b/lib/capybara-lockstep/helper.js @@ -1,10 +1,14 @@ window.CapybaraLockstep = (function() { + let originalSetTimeout = window.setTimeout; + let originalClearTimeout = window.clearTimeout; + // State and configuration let debug let jobCount let idleCallbacks let finishedWorkTags let defaultWaitTasks + let waitTimeoutMaxDelay reset() function reset() { @@ -13,6 +17,7 @@ window.CapybaraLockstep = (function() { finishedWorkTags = [] defaultWaitTasks = 1 debug = false + waitTimeoutMaxDelay = 10 } function isIdle() { @@ -176,6 +181,47 @@ window.CapybaraLockstep = (function() { }) } + function trackSetTimeout() { + let timeoutIds = new Set(); + + window.setTimeout = function(callback, delay, ...args) { + let isAsync = callback.constructor.name === 'AsyncFunction'; + let isValidDelay = typeof delay === 'undefined' ? true : delay >= 0; + let doWait = !isAsync && isValidDelay && (delay ?? 0) <= waitTimeoutMaxDelay; + + let timeoutId; + let wrappedCallback = () => { + try { + callback(...args); + } finally { + if (doWait) { + if (timeoutIds.delete(timeoutId)) { + stopWork('setTimeout()'); + } + timeoutIds.delete(timeoutId); + } + } + }; + + if (doWait) { + startWork('setTimeout()'); + } + timeoutId = originalSetTimeout(wrappedCallback, delay); + if (doWait) { + timeoutIds.add(timeoutId) + } + + return timeoutId; + }; + + window.clearTimeout = function(timeoutId) { + if (timeoutIds.delete(timeoutId)) { + stopWork('setTimeout()'); + } + return originalClearTimeout(timeoutId); + }; + } + function isRemoteScript(element) { return element.matches('script[src]') && !hasDataSource(element) && isTrackableScriptType(element.type) } @@ -241,7 +287,7 @@ window.CapybaraLockstep = (function() { } let scheduleCheckCondition = function() { - setTimeout(checkCondition, 150) + originalSetTimeout(checkCondition, 150) } element.addEventListener(loadEvent, doStop) @@ -281,7 +327,7 @@ window.CapybaraLockstep = (function() { function afterWaitTasks(fn, waitTasks = defaultWaitTasks) { if (waitTasks > 0) { // Wait 1 task and recurse - setTimeout(function() { + originalSetTimeout(function() { afterWaitTasks(fn, waitTasks - 1) }) } else { @@ -298,7 +344,7 @@ window.CapybaraLockstep = (function() { // Unpoly 1.0+ runs compilers on DOMContentLoaded, so there's no issue. if (window.up?.version?.startsWith('0.')) { startWork('Old Unpoly') - setTimeout(function () { + originalSetTimeout(function() { stopWork('Old Unpoly') }) } @@ -326,6 +372,7 @@ window.CapybaraLockstep = (function() { trackXHR() trackRemoteElements() trackJQuery() + trackSetTimeout() trackEvent(document, 'touchstart') trackEvent(document, 'mousedown') trackEvent(document, 'click') @@ -354,7 +401,8 @@ window.CapybaraLockstep = (function() { synchronize: synchronize, reset: reset, set debug(value) { debug = value }, - set waitTasks(value) { defaultWaitTasks = value } + set waitTasks(value) { defaultWaitTasks = value }, + set waitTimeoutMaxDelay(value) { waitTimeoutMaxDelay = value } } })() diff --git a/lib/capybara-lockstep/helper.rb b/lib/capybara-lockstep/helper.rb index a1bc188..2acb239 100644 --- a/lib/capybara-lockstep/helper.rb +++ b/lib/capybara-lockstep/helper.rb @@ -33,6 +33,10 @@ def capybara_lockstep_config_js(options = {}) js += "\nCapybaraLockstep.waitTasks = #{wait_tasks.to_json}" end + if (wait_timeout_max_delay = options.fetch(:wait_timeout_max_delay, Lockstep.wait_timeout_max_delay)) + js += "\nCapybaraLockstep.waitTimeoutMaxDelay = #{wait_timeout_max_delay.to_json}" + end + js end diff --git a/spec/features/synchronization_spec.rb b/spec/features/synchronization_spec.rb index 124209b..53d9003 100644 --- a/spec/features/synchronization_spec.rb +++ b/spec/features/synchronization_spec.rb @@ -506,4 +506,117 @@ end + describe 'settimeout' do + it 'waits for the timeout to complete' do + Capybara::Lockstep.wait_timeout_max_delay = 1000 + + App.start_script = <<~JS + setTimeout(() => { + document.querySelector('body').textContent = 'adjusted page' + }, 999) + JS + + visit '/start' + + expect(page).to have_content('adjusted page') + end + + it 'waits is there is no timeout specified' do + App.start_script = <<~JS + setTimeout(() => { + document.querySelector('body').textContent = 'adjusted page' + }) + JS + + visit '/start' + + expect(page).to have_content('adjusted page') + end + + it 'does not wait for the timeout to complete if it takes > configured wait timeout' do + Capybara::Lockstep.wait_timeout_max_delay = 1000 + + App.start_script = <<~JS + setTimeout(() => { + document.querySelector('body').textContent = 'adjusted page' + }, 1001) + JS + + visit '/start' + + expect(page).not_to have_content('adjusted page') + end + + it 'does not wait for the timeout to complete if the callback is an async function' do + Capybara::Lockstep.wait_timeout_max_delay = 1000 + + App.start_script = <<~JS + setTimeout(async () => { + document.querySelector('body').textContent = 'adjusted page' + }, 1000) + JS + visit '/start' + + expect(page).not_to have_content('adjusted page') + end + + it 'stops waiting if clearTimeout is called' do + Capybara::Lockstep.wait_timeout_max_delay = 1000 + + App.start_script = <<~JS + let timeoutId = setTimeout(() => { + document.querySelector('body').textContent = 'adjusted page' + }, 500) + clearTimeout(timeoutId) + JS + + visit '/start' + + expect(page).not_to have_content('adjusted page') + end + + it 'keeps waiting for the configured wait timeout max delay' do + Capybara::Lockstep.wait_timeout_max_delay = 3000 + + App.start_script = <<~JS + setTimeout(() => { + document.querySelector('body').textContent = 'adjusted page' + }, 2000) + JS + + visit '/start' + + expect(page).to have_content('adjusted page') + end + + it 'does not wait it the timeout is negative' do + App.start_script = <<~JS + setTimeout(() => {}, -1751537429808) + JS + + visit '/start' + + busy = page.evaluate_script(<<~JS) + CapybaraLockstep.isBusy() + JS + + expect(busy).to be(false) + end + + it 'calls stop work only once' do + App.start_script = <<~JS + let timeoutId = setTimeout(() => { + clearTimeout(timeoutId); + }) + JS + + visit '/start' + + busy = page.evaluate_script(<<~JS) + CapybaraLockstep.isBusy() + JS + + expect(busy).to be(false) + end + end end