Skip to content
14 changes: 14 additions & 0 deletions lib/capybara-lockstep/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 52 additions & 4 deletions lib/capybara-lockstep/helper.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -13,6 +17,7 @@ window.CapybaraLockstep = (function() {
finishedWorkTags = []
defaultWaitTasks = 1
debug = false
waitTimeoutMaxDelay = 10
}

function isIdle() {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -241,7 +287,7 @@ window.CapybaraLockstep = (function() {
}

let scheduleCheckCondition = function() {
setTimeout(checkCondition, 150)
originalSetTimeout(checkCondition, 150)
}

element.addEventListener(loadEvent, doStop)
Expand Down Expand Up @@ -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 {
Expand All @@ -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')
})
}
Expand Down Expand Up @@ -326,6 +372,7 @@ window.CapybaraLockstep = (function() {
trackXHR()
trackRemoteElements()
trackJQuery()
trackSetTimeout()
trackEvent(document, 'touchstart')
trackEvent(document, 'mousedown')
trackEvent(document, 'click')
Expand Down Expand Up @@ -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 }
}
})()

Expand Down
4 changes: 4 additions & 0 deletions lib/capybara-lockstep/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
113 changes: 113 additions & 0 deletions spec/features/synchronization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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