From c0d8c4494d34727c1588752d19e4bbfe3cd1557d Mon Sep 17 00:00:00 2001 From: YusukeIwaki Date: Sat, 1 Jan 2022 10:21:50 +0900 Subject: [PATCH 1/2] feat: implement Element.waitForSelector --- lib/puppeteer/custom_query_handler.rb | 4 +-- lib/puppeteer/dom_world.rb | 20 ++++++----- lib/puppeteer/element_handle.rb | 43 +++++++++++++++++++++++ lib/puppeteer/js_handle.rb | 8 +++++ lib/puppeteer/query_handler_manager.rb | 4 +-- lib/puppeteer/wait_task.rb | 41 ++++++++++++++-------- spec/integration/element_handle_spec.rb | 45 +++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 26 deletions(-) diff --git a/lib/puppeteer/custom_query_handler.rb b/lib/puppeteer/custom_query_handler.rb index 65bf7303..d0c8879f 100644 --- a/lib/puppeteer/custom_query_handler.rb +++ b/lib/puppeteer/custom_query_handler.rb @@ -21,12 +21,12 @@ def query_one(element, selector) nil end - def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil) + def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil, root: nil) unless @query_one raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.") end - dom_world.send(:wait_for_selector_in_page, @query_one, selector, visible: visible, hidden: hidden, timeout: timeout) + dom_world.send(:wait_for_selector_in_page, @query_one, selector, visible: visible, hidden: hidden, timeout: timeout, root: root) end def query_all(element, selector) diff --git a/lib/puppeteer/dom_world.rb b/lib/puppeteer/dom_world.rb index e1ba90b2..6e9cdfdc 100644 --- a/lib/puppeteer/dom_world.rb +++ b/lib/puppeteer/dom_world.rb @@ -420,10 +420,10 @@ def type_text(selector, text, delay: nil) # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false. # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false. # @param timeout [Integer] - def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil) + def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil, root: nil) # call wait_for_selector_in_page with custom query selector. query_selector_manager = Puppeteer::QueryHandlerManager.instance - query_selector_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout) + query_selector_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout, root: root) end private def binding_identifier(name, context) @@ -497,10 +497,11 @@ def add_binding_to_context(context, binding_function) # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false. # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false. # @param timeout [Integer] - private def wait_for_selector_in_page(query_one, selector, visible: nil, hidden: nil, timeout: nil, binding_function: nil) + private def wait_for_selector_in_page(query_one, selector, visible: nil, hidden: nil, timeout: nil, root: nil, binding_function: nil) option_wait_for_visible = visible || false option_wait_for_hidden = hidden || false option_timeout = timeout || @timeout_settings.timeout + option_root = root polling = if option_wait_for_visible || option_wait_for_hidden @@ -511,11 +512,11 @@ def add_binding_to_context(context, binding_function) title = "selector #{selector}#{option_wait_for_hidden ? 'to be hidden' : ''}" selector_predicate = make_predicate_string( - predicate_arg_def: '(selector, waitForVisible, waitForHidden)', + predicate_arg_def: '(root, selector, waitForVisible, waitForHidden)', predicate_query_handler: query_one, async: true, predicate_body: <<~JAVASCRIPT - const node = await predicateQueryHandler(document, selector) + const node = await predicateQueryHandler(root, selector) return checkWaitForOptions(node, waitForVisible, waitForHidden); JAVASCRIPT ) @@ -527,6 +528,7 @@ def add_binding_to_context(context, binding_function) polling: polling, timeout: option_timeout, args: [selector, option_wait_for_visible, option_wait_for_hidden], + root: option_root, binding_function: binding_function, ) handle = wait_task.await_promise @@ -541,10 +543,11 @@ def add_binding_to_context(context, binding_function) # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false. # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false. # @param timeout [Integer] - def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil) + def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil, root: nil) option_wait_for_visible = visible || false option_wait_for_hidden = hidden || false option_timeout = timeout || @timeout_settings.timeout + option_root = root polling = if option_wait_for_visible || option_wait_for_hidden @@ -555,9 +558,9 @@ def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil) title = "XPath #{xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}" xpath_predicate = make_predicate_string( - predicate_arg_def: '(selector, waitForVisible, waitForHidden)', + predicate_arg_def: '(root, selector, waitForVisible, waitForHidden)', predicate_body: <<~JAVASCRIPT - const node = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const node = document.evaluate(selector, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; return checkWaitForOptions(node, waitForVisible, waitForHidden); JAVASCRIPT ) @@ -569,6 +572,7 @@ def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil) polling: polling, timeout: option_timeout, args: [xpath, option_wait_for_visible, option_wait_for_hidden], + root: option_root, ) handle = wait_task.await_promise unless handle.as_element diff --git a/lib/puppeteer/element_handle.rb b/lib/puppeteer/element_handle.rb index 168d4a45..b15a3cf2 100644 --- a/lib/puppeteer/element_handle.rb +++ b/lib/puppeteer/element_handle.rb @@ -20,6 +20,49 @@ def initialize(context:, client:, remote_object:, page:, frame_manager:) @disposed = false end + def inspect + values = %i[context remote_object page disposed].map do |sym| + value = instance_variable_get(:"@#{sym}") + "@#{sym}=#{value}" + end + "#" + end + + # + # Wait for the `selector` to appear within the element. If at the moment of calling the + # method the `selector` already exists, the method will return immediately. If + # the `selector` doesn't appear after the `timeout` milliseconds of waiting, the + # function will throw. + # + # This method does not work across navigations or if the element is detached from DOM. + # + # @param selector - A + # {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + # of an element to wait for + # @param options - Optional waiting parameters + # @returns Promise which resolves when element specified by selector string + # is added to DOM. Resolves to `null` if waiting for hidden: `true` and + # selector is not found in DOM. + # @remarks + # The optional parameters in `options` are: + # + # - `visible`: wait for the selected element to be present in DOM and to be + # visible, i.e. to not have `display: none` or `visibility: hidden` CSS + # properties. Defaults to `false`. + # + # - `hidden`: wait for the selected element to not be found in the DOM or to be hidden, + # i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to + # `false`. + # + # - `timeout`: maximum time to wait in milliseconds. Defaults to `30000` + # (30 seconds). Pass `0` to disable timeout. The default value can be changed + # by using the {@link Page.setDefaultTimeout} method. + def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil) + @context.world.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout, root: self) + end + + define_async_method :async_wait_for_selector + def as_element self end diff --git a/lib/puppeteer/js_handle.rb b/lib/puppeteer/js_handle.rb index 04a5c0b6..1c7a4860 100644 --- a/lib/puppeteer/js_handle.rb +++ b/lib/puppeteer/js_handle.rb @@ -36,6 +36,14 @@ def initialize(context:, client:, remote_object:) attr_reader :context, :remote_object + def inspect + values = %i[context remote_object disposed].map do |sym| + value = instance_variable_get(:"@#{sym}") + "@#{sym}=#{value}" + end + "#" + end + # @return [Puppeteer::ExecutionContext] def execution_context @context diff --git a/lib/puppeteer/query_handler_manager.rb b/lib/puppeteer/query_handler_manager.rb index 82a230b3..725dd3b5 100644 --- a/lib/puppeteer/query_handler_manager.rb +++ b/lib/puppeteer/query_handler_manager.rb @@ -26,8 +26,8 @@ def query_one(element_handle) @query_handler.query_one(element_handle, @selector) end - def wait_for(dom_world, visible:, hidden:, timeout:) - @query_handler.wait_for(dom_world, @selector, visible: visible, hidden: hidden, timeout: timeout) + def wait_for(dom_world, visible:, hidden:, timeout:, root:) + @query_handler.wait_for(dom_world, @selector, visible: visible, hidden: hidden, timeout: timeout, root: root) end def query_all(element_handle) diff --git a/lib/puppeteer/wait_task.rb b/lib/puppeteer/wait_task.rb index f7ca295d..e1eb8fc3 100644 --- a/lib/puppeteer/wait_task.rb +++ b/lib/puppeteer/wait_task.rb @@ -9,7 +9,7 @@ def initialize(title:, timeout:) end end - def initialize(dom_world:, predicate_body:, title:, polling:, timeout:, args: [], binding_function: nil) + def initialize(dom_world:, predicate_body:, title:, polling:, timeout:, args: [], binding_function: nil, root: nil) if polling.is_a?(String) if polling != 'raf' && polling != 'mutation' raise ArgumentError.new("Unknown polling option: #{polling}") @@ -25,6 +25,7 @@ def initialize(dom_world:, predicate_body:, title:, polling:, timeout:, args: [] @dom_world = dom_world @polling = polling @timeout = timeout + @root = root @predicate_body = "return (#{predicate_body})(...args);" @args = args @binding_function = binding_function @@ -66,13 +67,24 @@ def rerun return if @terminated || run_count != @run_count begin - success = context.evaluate_handle( - WAIT_FOR_PREDICATE_PAGE_FUNCTION, - @predicate_body, - @polling, - @timeout, - *@args, - ) + if @root + success = @root.evaluate_handle( + WAIT_FOR_PREDICATE_PAGE_FUNCTION, + @predicate_body, + @polling, + @timeout, + *@args, + ) + else + success = context.evaluate_handle( + WAIT_FOR_PREDICATE_PAGE_FUNCTION, + nil, + @predicate_body, + @polling, + @timeout, + *@args, + ) + end rescue => err error = err end @@ -121,8 +133,9 @@ def rerun private define_async_method :async_rerun WAIT_FOR_PREDICATE_PAGE_FUNCTION = <<~JAVASCRIPT - async function _(predicateBody, polling, timeout, ...args) { + async function _(root, predicateBody, polling, timeout, ...args) { const predicate = new Function('...args', predicateBody); + root = root || document let timedOut = false; if (timeout) setTimeout(() => (timedOut = true), timeout); @@ -136,7 +149,7 @@ def rerun * @return {!Promise<*>} */ async function pollMutation() { - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) return Promise.resolve(success); let fulfill; const result = new Promise((x) => (fulfill = x)); @@ -145,13 +158,13 @@ def rerun observer.disconnect(); fulfill(); } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) { observer.disconnect(); fulfill(success); } }); - observer.observe(document, { + observer.observe(root, { childList: true, subtree: true, attributes: true, @@ -168,7 +181,7 @@ def rerun fulfill(); return; } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) fulfill(success); else requestAnimationFrame(onRaf); } @@ -183,7 +196,7 @@ def rerun fulfill(); return; } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) fulfill(success); else setTimeout(onTimeout, pollInterval); } diff --git a/spec/integration/element_handle_spec.rb b/spec/integration/element_handle_spec.rb index 88582c11..bd5fb8a6 100644 --- a/spec/integration/element_handle_spec.rb +++ b/spec/integration/element_handle_spec.rb @@ -279,6 +279,26 @@ end end + describe '#wait_for_selector' do + it 'should wait correctly with waitForSelector on an element' do + element = page.wait_for_selector('.foo') do + # Set the page content after the waitFor has been started. + page.content = '
bar2
Foo1
' + end + + inner_element = element.wait_for_selector('.bar') do + element.evaluate(<<~JAVASCRIPT) + (el) => { + el.innerHTML = '
bar1
'; + } + JAVASCRIPT + end + expect(inner_element).not_to be_nil + text = inner_element.evaluate('el => el.innerText') + expect(text).to eq('bar1') + end + end + describe '#hover' do it 'should work', sinatra: true do page.goto("#{server_prefix}/input/scrollable.html") @@ -423,7 +443,32 @@ # expect(element).toBeDefined(); # }); + # it('should wait correctly with waitForSelector on an element', async () => { + # const { page, puppeteer } = getTestState(); + # puppeteer.registerCustomQueryHandler('getByClass', { + # queryOne: (element, selector) => element.querySelector(`.${selector}`), + # }); + # const waitFor = page.waitForSelector('getByClass/foo'); + + # // Set the page content after the waitFor has been started. + # await page.setContent( + # '
bar2
Foo1
' + # ); + # let element = await waitFor; + # expect(element).toBeDefined(); + + # const innerWaitFor = element.waitForSelector('getByClass/bar'); + # await element.evaluate((el) => { + # el.innerHTML = '
bar1
'; + # }); + + # element = await innerWaitFor; + # expect(element).toBeDefined(); + # expect( + # await element.evaluate((el: HTMLElement) => el.innerText) + # ).toStrictEqual('bar1'); + # }); # it('should wait correctly with waitFor', async () => { # /* page.waitFor is deprecated so we silence the warning to avoid test noise */ # sinon.stub(console, 'warn').callsFake(() => {}); From 75c827fe9b03f2d01a1c81a8cf04a7167ad209f2 Mon Sep 17 00:00:00 2001 From: YusukeIwaki Date: Sat, 1 Jan 2022 22:35:10 +0900 Subject: [PATCH 2/2] fix: make sure ElementHandle.waitForSelector is evaluated in the right context --- lib/puppeteer/aria_query_handler.rb | 8 ++++--- lib/puppeteer/element_handle.rb | 13 ++++++++++- lib/puppeteer/wait_task.rb | 26 +++++++-------------- spec/integration/aria_query_handler_spec.rb | 19 ++++++++++++--- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/lib/puppeteer/aria_query_handler.rb b/lib/puppeteer/aria_query_handler.rb index d67f0100..bde3c1bb 100644 --- a/lib/puppeteer/aria_query_handler.rb +++ b/lib/puppeteer/aria_query_handler.rb @@ -36,11 +36,11 @@ def query_one(element, selector) end end - def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil) + def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil, root: nil) # addHandlerToWorld binding_function = Puppeteer::DOMWorld::BindingFunction.new( name: 'ariaQuerySelector', - proc: -> (sel) { query_one(dom_world.send(:document), sel) }, + proc: -> (sel) { query_one(root || dom_world.send(:document), sel) }, ) dom_world.send(:wait_for_selector_in_page, '(_, selector) => globalThis.ariaQuerySelector(selector)', @@ -48,7 +48,9 @@ def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil) visible: visible, hidden: hidden, timeout: timeout, - binding_function: binding_function) + binding_function: binding_function, + root: root, + ) end def query_all(element, selector) diff --git a/lib/puppeteer/element_handle.rb b/lib/puppeteer/element_handle.rb index b15a3cf2..1bab748e 100644 --- a/lib/puppeteer/element_handle.rb +++ b/lib/puppeteer/element_handle.rb @@ -58,7 +58,18 @@ def inspect # (30 seconds). Pass `0` to disable timeout. The default value can be changed # by using the {@link Page.setDefaultTimeout} method. def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil) - @context.world.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout, root: self) + frame = @context.frame + + secondary_world = frame.secondary_world + adopted_root = secondary_world.execution_context.adopt_element_handle(self) + handle = secondary_world.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout, root: adopted_root) + adopted_root.dispose + return nil unless handle + + main_world = frame.main_world + result = main_world.execution_context.adopt_element_handle(handle) + handle.dispose + result end define_async_method :async_wait_for_selector diff --git a/lib/puppeteer/wait_task.rb b/lib/puppeteer/wait_task.rb index e1eb8fc3..4a36b027 100644 --- a/lib/puppeteer/wait_task.rb +++ b/lib/puppeteer/wait_task.rb @@ -67,24 +67,14 @@ def rerun return if @terminated || run_count != @run_count begin - if @root - success = @root.evaluate_handle( - WAIT_FOR_PREDICATE_PAGE_FUNCTION, - @predicate_body, - @polling, - @timeout, - *@args, - ) - else - success = context.evaluate_handle( - WAIT_FOR_PREDICATE_PAGE_FUNCTION, - nil, - @predicate_body, - @polling, - @timeout, - *@args, - ) - end + success = context.evaluate_handle( + WAIT_FOR_PREDICATE_PAGE_FUNCTION, + @root, + @predicate_body, + @polling, + @timeout, + *@args, + ) rescue => err error = err end diff --git a/spec/integration/aria_query_handler_spec.rb b/spec/integration/aria_query_handler_spec.rb index e10cbf8c..428d14b9 100644 --- a/spec/integration/aria_query_handler_spec.rb +++ b/spec/integration/aria_query_handler_spec.rb @@ -93,6 +93,19 @@ Timeout.timeout(1) { page.wait_for_selector('aria/[role="button"]') } end + it 'should return the element handle' do + page.evaluate(<<~JAVASCRIPT) + () => (document.body.innerHTML = `
`) + JAVASCRIPT + element = page.query_selector('div') + inner_element = element.wait_for_selector('aria/test') do + element.evaluate(<<~JAVASCRIPT) + el => el.innerHTML="

" + JAVASCRIPT + end + expect(inner_element.evaluate('el => el.outerHTML')).to eq('') + end + it 'should persist query handler bindings across reloads' do page.goto(server_empty_page) page.evaluate(add_element, 'button') @@ -372,9 +385,9 @@ # }); it 'should return the element handle' do - promise = page.async_wait_for_selector('aria/zombo') - page.content = "
anything
" - result = await promise + result = page.wait_for_selector('aria/zombo') do + page.content = "
anything
" + end expect(result).to be_a(Puppeteer::ElementHandle) expect(page.evaluate('(x) => x.textContent', result)).to eq('anything') end