From 75d431170b1067cf58fc28b8fb5637ffe9b6b1a9 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Sun, 23 Aug 2026 20:10:50 +0300 Subject: [PATCH 1/2] feat: redesign the JavaScript evaluation API around named arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#evaluate`, `#evaluate_async`, `#execute`, `#evaluate_func` and `#evaluate_on` had four different argument conventions and three different return semantics between them. Scrip`#evaluate_async` spliced its resolve callback in at `arguments[arguments.length]`, so the callback's index shifted with the number of arguments passed. Neither Puppeteer nor Playwright ships an asynchronous variant at all — both always await the returned promise — and CDP has `awaitPromise`, so none of that machinery was still earning its place. The API is now three entry points that share one script shape and one argument style, plus the same three on `Node` with `this` bound to the element: - Keyword arguments become the script's function parameters, in order: `page.evaluate("a + b", a: 1, b: 2)`. Each is sent as its own protocol argument, so a `Node` still arrives in JavaScript as the live element. When the script is a function declaration, values bind to its own parameter names rather than to hash order. - A script is either a bare expression, which gets wrapped, or a function/arrow declaration, which is used as-is. This folds `#evaluate_func` into `#evaluate` and is how multi-statement scripts are written. - Promises are always awaited, so `evaluate("await fetch(url)", url: "/x")` works without a separate method. `timeout:` (seconds, defaulting to the page timeout) bounds the script browser-side and raises `ScriptTimeoutError`; `0` disables it. - `#evaluate_handle` returns a `Ferrum::RemoteObject`, an opaque reference to a browser-side value that can be passed straight back in as an argument. - Added `Node#execute` and `Node#evaluate_handle`.ts reached their arguments through `arguments[0]`, and Nothing breaks. `arguments[n]` is still populated inside the generated function, `#evaluate_on` remain as shims. All four paths warn once per message and call site; `FERRUM_DEPRECATION_WARNINGS=raise` turns the warnings into errors while migrating a suite, `=0` silences them. Two behaviour changes worth noting: `Node#evaluate` resolves its result the way `Page#evaluate` does, so `node.evaluate("this.parentNode")` returns a `Node` instead of an empty hash — it previously ran with `returnByValue`, which flattened every DOM result — and `#evaluate` now awaits a returned promise instead of serializing it to an empty object. Detection of a function declaration is deliberately conservative: a script beginning with `function` counts as one only if it ends at the closing brace, so the IIFE `function() { ... }()` stays an expression, and arrow detection accepts only a plain identifier parameter list, so `(() => 1)()` does too. --- CHANGELOG.md | 26 ++ README.md | 54 ++++ lib/ferrum.rb | 2 + lib/ferrum/browser.rb | 3 +- lib/ferrum/frame.rb | 7 +- lib/ferrum/frame/dom.rb | 94 ++++--- lib/ferrum/frame/runtime.rb | 472 ++++++++++++++++++++++++++++----- lib/ferrum/node.rb | 87 ++++-- lib/ferrum/page.rb | 3 +- lib/ferrum/page/screenshot.rb | 15 +- lib/ferrum/remote_object.rb | 71 +++++ lib/ferrum/utils/deprecate.rb | 58 ++++ sig/ferrum/frame/runtime.rbs | 54 +++- sig/ferrum/node.rbs | 6 +- sig/ferrum/remote_object.rbs | 21 ++ sig/ferrum/utils/deprecate.rbs | 13 + spec/frame/runtime_spec.rb | 204 ++++++++++++++ spec/mouse_spec.rb | 6 +- spec/unit/browser_spec.rb | 4 +- spec/worker_spec.rb | 4 +- 20 files changed, 1036 insertions(+), 168 deletions(-) create mode 100644 lib/ferrum/remote_object.rb create mode 100644 lib/ferrum/utils/deprecate.rb create mode 100644 sig/ferrum/remote_object.rbs create mode 100644 sig/ferrum/utils/deprecate.rbs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c54443a..e75ee4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ loaded (on CDP's `Network.loadingFinished`, not `Network.responseReceived`, so `exchange.response.body` is always available), yielding the request's `Network::Exchange`. Doesn't require `network.intercept` to be set up, and is never fired for requests that fail to load [#294] +- `Ferrum::Frame#evaluate` (and `Page`/`Browser`/`Node`) accept **named arguments**, which become the script's + function parameters in order, so scripts name what they receive instead of reaching into `arguments[0]`: + `page.evaluate("a + b", a: 1, b: 2)`. Each keyword is sent as its own protocol argument, so a `Ferrum::Node` + still arrives in JavaScript as the live element. When the script is a function declaration, values are bound + to its own parameter names rather than to hash order +- `#evaluate`/`#execute`/`#evaluate_handle` accept either a bare expression, which is wrapped for you, or a + function/arrow declaration, which is used as-is. This folds `#evaluate_func` into `#evaluate` and is how you + run multi-statement scripts +- Promises are now always awaited (`awaitPromise`), so `page.evaluate("await fetch(url)", url: "/x")` works and + there is no need for a separate asynchronous method. A `timeout:` keyword (seconds, defaulting to the page + timeout) bounds how long a script may take before `Ferrum::ScriptTimeoutError`; `0` disables it +- `Ferrum::Frame#evaluate_handle` and `Ferrum::Node#evaluate_handle` return a `Ferrum::RemoteObject`, an opaque + reference to a browser-side value that can be passed straight back in as an argument without being serialized +- `Ferrum::Node#execute` and `Ferrum::Node#evaluate_handle`, matching the page-level API but with `this` bound + to the node +- `FERRUM_DEPRECATION_WARNINGS` controls the new deprecation warnings: `raise` turns them into errors while + migrating a suite, `0` silences them - `Ferrum::Browser#quit`/`Ferrum::Browser::Process#stop` accept `wait: false` to return immediately and run process killing and user-data-directory cleanup on a background thread instead of blocking; the call returns the `Thread` so callers can `#join` it if they need cleanup to have finished, e.g. before process exit or before reusing a @@ -18,6 +35,15 @@ - `Ferrum::Page::Stream#stream` now closes the CDP stream handle (`IO.close`) once it's been fully read, so streams opened for `Ferrum::Browser#pdf` and `Ferrum::Page::Tracing#record` no longer keep their backing storage alive in the browser; a failed `IO.close` is raised to the caller. +- `Ferrum::Frame#evaluate_async`, `#evaluate_func` and `#evaluate_on` are deprecated and warn when called; so does + passing positional arguments to `#evaluate`/`#execute`. All of them keep working — `arguments[n]` is still + populated inside the generated function — and will be removed in the next major release. Replacements: + `evaluate_async(expr, wait, *args)` becomes `evaluate("await …", timeout: wait)`, `evaluate_func(fn, *args)` + becomes `evaluate(fn, name: value)`, and `evaluate_on(node:, expression:)` becomes `node.evaluate(expression)` +- `Ferrum::Node#evaluate` resolves its result the same way `Page#evaluate` does, so `node.evaluate("this.parentNode")` + returns a `Ferrum::Node` instead of an empty hash. It previously ran with `returnByValue`, which flattened + every DOM result +- `Ferrum::Frame#evaluate` now awaits a returned promise instead of serializing it to an empty object ### Fixed - `#evaluate`/`#evaluate_on`/etc. resolved an object/array result by making two CDP round trips diff --git a/README.md b/README.md index 7c3a694d..2ffa88f6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,60 @@ JS browser.quit ``` +Pass arguments by name, so the script can use parameters instead of digging +through `arguments[0]`: + +```ruby +page.evaluate("a + b", a: 1, b: 2) # => 3 +page.evaluate("el.getAttribute(name)", el: page.at_css("a"), name: "href") +``` + +A script that starts with a function or arrow declaration is used as-is, which +is how you run more than one statement: + +```ruby +page.evaluate(<<~JS, c: 3) + function(a, b) { + const sum = a + b; + return sum * c; + } +JS +``` + +Promises are always awaited, so `async`/`await` works directly: + +```ruby +page.evaluate("await fetch(url).then(r => r.text())", url: "/api") +page.evaluate("new Promise(resolve => setTimeout(() => resolve(42), 100))") # => 42 +``` + +Pass `timeout:` (seconds, defaulting to the page timeout) to bound how long the +script may take before `Ferrum::ScriptTimeoutError` is raised. `timeout:` and +`args:` are the only reserved keywords; use `args:` when a JavaScript parameter +needs one of those names: + +```ruby +page.evaluate("timeout * 2", args: { timeout: 21 }) # => 42 +``` + +Use `#execute` when you only want the side effects, `#evaluate_handle` when you +want to keep a value in the browser and pass it back in later, and +`Ferrum::Node#evaluate` to run a script with `this` bound to an element: + +```ruby +page.execute("window.scrollBy(0, 100)") # => true + +list = page.evaluate_handle("document.querySelectorAll('li')") +page.evaluate("Array.from(nodes).map(n => n.textContent)", nodes: list) + +page.at_css("input").evaluate("this.value") +``` + +`#evaluate_async`, `#evaluate_func` and `#evaluate_on` are deprecated in favour +of the above and warn when called. Positional arguments still work but warn too. +Set `FERRUM_DEPRECATION_WARNINGS=raise` to turn the warnings into errors while +migrating, or `=0` to silence them. + Do any mouse movements you like: ```ruby diff --git a/lib/ferrum.rb b/lib/ferrum.rb index f10eb4f5..80a66798 100644 --- a/lib/ferrum.rb +++ b/lib/ferrum.rb @@ -6,8 +6,10 @@ require "ferrum/utils/platform" require "ferrum/utils/elapsed_time" require "ferrum/utils/attempt" +require "ferrum/utils/deprecate" require "ferrum/errors" require "ferrum/browser" +require "ferrum/remote_object" require "ferrum/node" # diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 4cea1153..65c0219e 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -36,7 +36,8 @@ class Browser screenshot pdf mhtml viewport_size device_pixel_ratio start_screencast stop_screencast frames frame_by main_frame - evaluate evaluate_on evaluate_async execute evaluate_func + evaluate evaluate_handle execute evaluate_in + evaluate_on evaluate_async evaluate_func add_script_tag add_style_tag bypass_csp on position position= playback_rate playback_rate= diff --git a/lib/ferrum/frame.rb b/lib/ferrum/frame.rb index 40dfcff5..1078d658 100644 --- a/lib/ferrum/frame.rb +++ b/lib/ferrum/frame.rb @@ -165,12 +165,11 @@ def parent # frame.body # =>

lol

# def content=(html) - evaluate_async(%( + execute(<<~JS, html: html) document.open(); - document.write(arguments[0]); + document.write(html); document.close(); - arguments[1](true); - ), @page.timeout, html) + JS @page.document_node_id end alias set_content content= diff --git a/lib/ferrum/frame/dom.rb b/lib/ferrum/frame/dom.rb index 22db9476..a01700be 100644 --- a/lib/ferrum/frame/dom.rb +++ b/lib/ferrum/frame/dom.rb @@ -27,32 +27,38 @@ class Frame # module DOM SCRIPT_SRC_TAG = <<~JS - const script = document.createElement("script"); - script.src = arguments[0]; - script.type = arguments[1]; - script.onload = arguments[2]; - document.head.appendChild(script); + function(url, type) { + const script = document.createElement("script"); + script.src = url; + script.type = type; + document.head.appendChild(script); + return new Promise(resolve => script.onload = resolve); + } JS SCRIPT_TEXT_TAG = <<~JS - const script = document.createElement("script"); - script.text = arguments[0]; - script.type = arguments[1]; - document.head.appendChild(script); - arguments[2](); + function(content, type) { + const script = document.createElement("script"); + script.text = content; + script.type = type; + document.head.appendChild(script); + } JS STYLE_TAG = <<~JS - const style = document.createElement("style"); - style.type = "text/css"; - style.appendChild(document.createTextNode(arguments[0])); - document.head.appendChild(style); - arguments[1](); + function(content) { + const style = document.createElement("style"); + style.type = "text/css"; + style.appendChild(document.createTextNode(content)); + document.head.appendChild(style); + } JS LINK_TAG = <<~JS - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = arguments[0]; - link.onload = arguments[1]; - document.head.appendChild(link); + function(url) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + return new Promise(resolve => link.onload = resolve); + } JS # @@ -159,7 +165,7 @@ def xpath(selector, within: nil) } JS - evaluate_func(expr, selector, within) + evaluate(expr, selector: selector, within: within) end # @@ -186,7 +192,7 @@ def at_xpath(selector, within: nil) return xpath.snapshotItem(0); } JS - evaluate_func(expr, selector, within) + evaluate(expr, selector: selector, within: within) end # @@ -213,7 +219,7 @@ def css(selector, within: nil) } JS - evaluate_func(expr, selector, within) + evaluate(expr, selector: selector, within: within) end # @@ -240,7 +246,7 @@ def at_css(selector, within: nil) } JS - evaluate_func(expr, selector, within) + evaluate(expr, selector: selector, within: within) end # @@ -306,17 +312,17 @@ def wait_for_selector(css: nil, xpath: nil, within: nil, timeout: @page.timeout, # browser.add_script_tag(url: "http://example.com/stylesheet.css") # => true # def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript") - expr, *args = if url - [SCRIPT_SRC_TAG, url, type] - elsif path || content - if path - content = File.read(path) - content += "\n//# sourceURL=#{path}" - end - [SCRIPT_TEXT_TAG, content, type] - end + if url + evaluate(SCRIPT_SRC_TAG, url: url, type: type) + elsif path || content + if path + content = File.read(path) + content += "\n//# sourceURL=#{path}" + end + evaluate(SCRIPT_TEXT_TAG, content: content, type: type) + end - evaluate_async(expr, @page.timeout, *args) + true end # @@ -332,17 +338,17 @@ def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript") # browser.add_style_tag(content: "h1 { font-size: 40px; }") # => true # def add_style_tag(url: nil, path: nil, content: nil) - expr, *args = if url - [LINK_TAG, url] - elsif path || content - if path - content = File.read(path) - content += "\n//# sourceURL=#{path}" - end - [STYLE_TAG, content] - end + if url + evaluate(LINK_TAG, url: url) + elsif path || content + if path + content = File.read(path) + content += "\n//# sourceURL=#{path}" + end + evaluate(STYLE_TAG, content: content) + end - evaluate_async(expr, @page.timeout, *args) + true end end end diff --git a/lib/ferrum/frame/runtime.rb b/lib/ferrum/frame/runtime.rb index 84592539..826ae5ee 100644 --- a/lib/ferrum/frame/runtime.rb +++ b/lib/ferrum/frame/runtime.rb @@ -22,55 +22,222 @@ def inspect class Frame # - # Evaluates and executes JavaScript in a frame's execution context via + # Runs JavaScript in a frame's execution context via # `Runtime.callFunctionOn`, converting arguments and return values # between Ruby and JS, and resolving object/array/node results # (including cyclic ones, via {CyclicObject}) into Ruby equivalents. # + # There are three entry points, all of which accept the script in the + # same two shapes and pass arguments the same way: + # + # * {#evaluate} returns the value, serialized into Ruby. + # * {#evaluate_handle} returns a {RemoteObject} that stays in the browser. + # * {#execute} discards the value and returns `true`. + # + # ## Script shapes + # + # A script is either a bare expression, which gets wrapped in a function + # for you, or a function declaration, which is used as-is. Use the latter + # whenever you need more than one statement. + # + # page.evaluate("window.scrollY") + # page.evaluate("function() { const a = 1; return a + 1 }") + # + # ## Arguments + # + # Keyword arguments become the function's parameters, in order, so the + # script can name what it receives instead of digging through + # `arguments[0]`: + # + # page.evaluate("a + b", a: 1, b: 2) + # + # Each keyword is sent as its own protocol argument, so a {Node} arrives + # in JavaScript as the live element rather than as serialized JSON. + # + # `timeout:` and `args:` are reserved. Pass `args:` explicitly when you + # need a JavaScript parameter that happens to be named after one of them: + # + # page.evaluate("timeout * 2", args: { timeout: 21 }) + # + # ## Promises + # + # Promises are always awaited, so `async`/`await` works directly and + # there is no separate asynchronous method: + # + # page.evaluate("await fetch(url).then(r => r.text())", url: "/api") + # + # If the script hasn't settled within `timeout:` seconds (defaulting to + # the page's timeout) a {ScriptTimeoutError} is raised. + # module Runtime INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f + # Marker rejected browser-side when a script outlives its timeout. + SCRIPT_TIMEOUT = "FERRUM_SCRIPT_TIMEOUT" + + # The same marker used by the deprecated {#evaluate_async}. + LEGACY_SCRIPT_TIMEOUT = "timed out promise" + + # Matches a script whose whole body is a function declaration. Anything + # trailing the closing brace means it is an expression that merely + # starts with `function`, e.g. the IIFE `function() { ... }()`. + FUNCTION_DECLARATION = /\A(?:async\s+)?function[\s*(]/ + + # Matches an arrow declaration. Parameters are restricted to a plain + # identifier list so that a parenthesized arrow call, `(() => 1)()`, + # stays an expression. + ARROW_DECLARATION = /\A(?:async\s+)?(?: + \(\s*(?:[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*\s*)?\)\s*=> | # (a, b) => + [A-Za-z_$][\w$]*\s*=> # a => + )/x + + # Trailing whitespace, semicolons and comments, which don't count as + # content following a function's closing brace. + TRAILING_NOISE = %r{(?:\s|;|//[^\n]*|/\*.*?\*/)*\z}m + + JS_IDENTIFIER = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/ + + # Reads the parameter names out of a function or arrow declaration, so + # that named arguments can be bound by name rather than by hash order. + PARAMETER_LISTS = [ + /\Afunction\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)/m, # function foo(a, b) + /\A\(([^)]*)\)\s*=>/m, # (a, b) => + /\A([A-Za-z_$][\w$]*)\s*=>/ # a => + ].freeze + + # The public method each internal mode belongs to, for error messages. + PUBLIC_NAMES = { value: "evaluate", handle: "evaluate_handle", none: "execute" }.freeze + # - # Evaluate and return result for given JS expression. + # Evaluates JavaScript and returns the result, serialized into Ruby. # # @param [String] expression - # The JavaScript to evaluate. + # A JavaScript expression, or a function declaration. # - # @param [Array] args - # Additional arguments to pass to the JavaScript code. + # @param [Numeric, nil] timeout + # How long to wait for the script (and any promise it returns) to + # settle, in seconds. Defaults to the page timeout. `0` disables it. + # + # @param [Hash, nil] args + # Arguments to pass, when a name would collide with a reserved + # keyword. Takes the place of `**named`. + # + # @param [Hash] named + # Arguments to pass, becoming the function's parameters in order. + # + # @return [Object] + # The result. DOM nodes come back as {Node}, arrays and plain objects + # are converted recursively, and cyclic values become {CyclicObject}. + # + # @raise [ScriptTimeoutError] + # The script didn't settle within `timeout`. + # + # @raise [JavaScriptError] + # The script threw. + # + # @example + # browser.evaluate("[window.scrollX, window.scrollY]") # => [0, 0] + # browser.evaluate("a + b", a: 1, b: 2) # => 3 + # browser.evaluate("await fetch(url).then(r => r.status)", url: "/") # => 200 + # + def evaluate(expression, *positional, timeout: nil, args: nil, **named) + run(expression, positional, (args || {}).merge(named), mode: :value, timeout: timeout) + end + + # + # Same as {#evaluate}, but returns a {RemoteObject} that keeps the value + # in the browser instead of serializing it. Handles can be passed back + # in as arguments. Primitives are returned as-is, and DOM nodes as + # {Node}, since both are already usable from Ruby. + # + # @param (see #evaluate) + # + # @return [RemoteObject, Node, Object] # # @example - # browser.evaluate("[window.scrollX, window.scrollY]") + # list = page.evaluate_handle("document.querySelectorAll('li')") + # page.evaluate("Array.from(nodes).map(n => n.textContent)", nodes: list) + # + def evaluate_handle(expression, *positional, timeout: nil, args: nil, **named) + run(expression, positional, (args || {}).merge(named), mode: :handle, timeout: timeout) + end + + # + # Runs JavaScript for its side effects and discards the result. Unlike + # {#evaluate}, the script is used as a function body rather than an + # expression, so multiple statements need no wrapping. + # + # @param (see #evaluate) + # + # @return [Boolean] + # Always `true`. + # + # @example + # browser.execute("window.scrollBy(0, 100)") # => true + # browser.execute(<<~JS, url: "/next") + # history.pushState({}, "", url); + # window.dispatchEvent(new Event("popstate")); + # JS + # + def execute(expression, *positional, timeout: nil, args: nil, **named) + run(expression, positional, (args || {}).merge(named), mode: :none, timeout: timeout) + true + end + + # + # @api private # - def evaluate(expression, *args) - expression = format("function() { return %s }", expression) - call(expression: expression, arguments: args) + # Backs {Node#evaluate}, {Node#evaluate_handle} and {Node#execute}. Runs + # the script with `this` bound to `node`, using the same argument and + # script conventions as {#evaluate}. + # + # @param [Node] node + # The node to bind `this` to. + # + # @param [Symbol] mode + # `:value`, `:handle` or `:none`. + # + # @return [Object] + # + def evaluate_in(node, expression, positional, args, mode: :value, timeout: nil) + run(expression, positional, args, mode: mode, timeout: timeout, on: node) end # - # Evaluate asynchronous expression and return result. + # @deprecated Use {#evaluate}, which always awaits promises. Write + # `page.evaluate("await thing()")` instead of passing a callback + # through `arguments`. + # + # Evaluates an asynchronous expression, appending a resolve callback as + # the last entry of `arguments`. # # @param [String] expression # The JavaScript to evaluate. # # @param [Integer] wait - # How long we should wait for Promise to resolve or reject. + # How long to wait for the promise to settle, in seconds. # # @param [Array] args - # Additional arguments to pass to the JavaScript code. + # Additional arguments, reachable as `arguments[0]` and up. The + # resolve callback follows them. # - # @example - # browser.evaluate_async(%(arguments[0]({foo: "bar"})), 5) # => { "foo" => "bar" } + # @return [Object] # def evaluate_async(expression, wait, *args) + Utils::Deprecate.warn( + "#{self.class}#evaluate_async", + "Use #evaluate instead, which always awaits promises: " \ + "evaluate(\"await thing()\", timeout: #{wait})." + ) + template = <<~JS function() { return new Promise((__f, __r) => { try { arguments[arguments.length] = r => __f(r); arguments.length = arguments.length + 1; - setTimeout(() => __r(new Error("timed out promise")), %s); + setTimeout(() => __r(new Error("#{LEGACY_SCRIPT_TIMEOUT}")), %s); %s } catch(error) { __r(error); @@ -79,32 +246,17 @@ def evaluate_async(expression, wait, *args) } JS - expression = format(template, wait * 1000, expression) - call(expression: expression, arguments: args, awaitPromise: true) + declaration = format(template, wait * 1000, expression) + call(declaration, args, mode: :value) end # - # Execute expression. Doesn't return the result. + # @deprecated Use {#evaluate}, which accepts a function declaration + # directly and names arguments with keywords. For `on:`, use + # {Node#evaluate}. # - # @param [String] expression - # The JavaScript to evaluate. - # - # @param [Array] args - # Additional arguments to pass to the JavaScript code. - # - # @example - # browser.execute(%(1 + 1)) # => true - # - def execute(expression, *args) - expression = format("function() { %s }", expression) - call(expression: expression, arguments: args, handle: false, returnByValue: true) - true - end - - # - # Evaluates a raw JS function declaration (unlike {#evaluate}, which - # wraps the given expression in one), optionally on a specific remote - # object instead of the frame's global execution context. + # Evaluates a raw JS function declaration, optionally on a specific + # remote object instead of the frame's global execution context. # # @param [String] expression # A JS function declaration, e.g. `"function(a, b) { return a + b }"`. @@ -115,14 +267,23 @@ def execute(expression, *args) # @param [Node, nil] on # Remote object to invoke the function on. # + # @return [Object] + # def evaluate_func(expression, *args, on: nil) - call(expression: expression, arguments: args, on: on) + Utils::Deprecate.warn( + "#{self.class}#evaluate_func", + "Use #evaluate, which accepts a function declaration and names arguments with keywords: " \ + "evaluate(\"function(a, b) { ... }\", a: 1, b: 2). For `on:`, use Node#evaluate." + ) + + call(expression, args, on: on, mode: :value) end + # + # @deprecated Use {Node#evaluate}. # # Evaluates an expression against a given node's remote object (+this+ - # refers to the node), returning the raw JS value rather than - # resolving it to a {Node}/Hash/Array. + # refers to the node). # # @param [Node] node # The node to evaluate the expression on. @@ -131,59 +292,223 @@ def evaluate_func(expression, *args, on: nil) # The JavaScript to evaluate. # # @param [Boolean] by_value - # Whether to return the plain JS value instead of a handle. + # Whether to return the plain JS value instead of resolving it. # # @param [Integer] wait # Passed through to the underlying `Runtime.callFunctionOn` command. # + # @return [Object] + # def evaluate_on(node:, expression:, by_value: true, wait: 0) - options = { handle: true } - expression = format("function() { return %s }", expression) - options = { handle: false, returnByValue: true } if by_value - call(expression: expression, on: node, wait: wait, **options) + Utils::Deprecate.warn( + "#{self.class}#evaluate_on", + "Use Node#evaluate instead: node.evaluate(\"this.value\")." + ) + + declaration = format("function() { return %s }", expression) + call(declaration, on: node, wait: wait, mode: by_value ? :raw : :value) end private - def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options) + # + # Wraps the caller's script and hands it to {#call}. `mode` decides both + # how the result is converted and whether the script's value is kept. + # + def run(expression, positional, args, mode: :value, timeout: nil, on: nil) + params, arguments = arguments_for(expression, positional, args, mode) + seconds = script_timeout(timeout) + declaration = wrap_expression(expression, params, seconds, returns: mode != :none) + # The browser-side race is what raises ScriptTimeoutError, so the + # transport needs a longer budget than the script itself. + call(declaration, arguments, on: on, mode: mode, timeout: [seconds + 1, @page.timeout].max) + end + + # + # Normalizes the two argument styles into a parameter list and a list of + # values, warning when the deprecated positional style is used. + # + def arguments_for(expression, positional, args, mode) + if positional.any? + raise ArgumentError, "Pass arguments either positionally or by name, not both" if args.any? + + method = PUBLIC_NAMES.fetch(mode) + Utils::Deprecate.warn( + "#{self.class}##{method} with positional arguments", + "Name them instead, so the script can use parameters rather than `arguments[0]`: " \ + "#{method}(\"a + b\", a: 1, b: 2)." + ) + + return [[], positional] + end + + return [[], []] if args.empty? + + if declaration?(expression) + # The script names its own parameters; we only supply the values, + # ordered to match the declaration when we can read it. + [[], ordered_values(expression, args)] + else + [args.keys.map { |key| validate_parameter!(key) }, args.values] + end + end + + # + # Orders a hash of named arguments to match a declaration's own + # parameter list, so that `evaluate("function(a, b) { … }", b: 2, a: 1)` + # binds by name rather than by insertion order. Falls back to insertion + # order when the parameters can't be read or don't line up. + # + def ordered_values(expression, hash) + names = declared_parameters(expression) + return hash.values unless names && names.sort == hash.keys.map(&:to_s).sort + + names.map { |name| hash.key?(name.to_sym) ? hash[name.to_sym] : hash[name] } + end + + # + # Whether the script is a complete function or arrow declaration, rather + # than an expression that happens to begin with one. + # + def declaration?(expression) + source = expression.strip + return true if source.match?(ARROW_DECLARATION) + return false unless source.match?(FUNCTION_DECLARATION) + + # A declaration ends at its closing brace; an IIFE has a call after it. + source.sub(TRAILING_NOISE, "").end_with?("}") + end + + def declared_parameters(expression) + head = expression.strip.sub(/\Aasync\s+/, "") + list = PARAMETER_LISTS.lazy.filter_map { |pattern| head.match(pattern)&.[](1) }.first + return unless list + + names = list.split(",").map(&:strip).reject(&:empty?) + names if names.all? { |name| name.match?(JS_IDENTIFIER) } + end + + def validate_parameter!(key) + name = key.to_s + return name if name.match?(JS_IDENTIFIER) + + raise ArgumentError, "#{name.inspect} is not a valid JavaScript parameter name" + end + + # + # Builds the function declaration that is actually sent to the browser: + # the caller's script, wrapped so that a promise it returns is raced + # against a browser-side timeout. + # + def wrap_expression(expression, params, timeout, returns:) + inner = if declaration?(expression) + # The declaration is spliced into an expression position, so + # a trailing semicolon would be a syntax error there. + expression.strip.sub(TRAILING_NOISE, "") + else + body = if expression.strip.empty? + "" + elsif returns + # Parenthesized on its own lines so that neither + # automatic semicolon insertion nor a trailing `//` + # comment in the caller's expression can swallow it. + "return (\n#{expression.strip.sub(TRAILING_NOISE, '')}\n);" + else + expression + end + "async function(#{params.join(', ')}) {\n#{body}\n}" + end + + return inner unless timeout.positive? + + # `inner` sits on its own lines so a trailing `//` comment in the + # caller's script can't comment out the rest of the wrapper. + <<~JS + async function() { + const __ferrum_fn = ( + #{inner} + ); + let __ferrum_timer; + try { + return await Promise.race([ + Promise.resolve(__ferrum_fn.apply(this, arguments)), + new Promise((_, reject) => { + __ferrum_timer = setTimeout(() => reject(new Error("#{SCRIPT_TIMEOUT}")), + #{(timeout * 1000).round}); + }) + ]); + } finally { + clearTimeout(__ferrum_timer); + } + } + JS + end + + # + # @param [Symbol] mode + # `:value` resolves the result into Ruby, `:handle` returns a + # {RemoteObject}, `:raw` returns the serialized JS value untouched, + # and `:none` discards it. + # + def call(declaration, arguments = [], on: nil, wait: 0, mode: :value, timeout: nil) # do not rescue -> retry if we operate on an existing node errors = on ? [] : [NodeNotFoundError, NoExecutionContextError] Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do - params = options.dup - - if on - response = @page.command("DOM.resolveNode", nodeId: on.node_id) - object_id = response.dig("object", "objectId") - params = params.merge(objectId: object_id) - end - - if params[:executionContextId].nil? && params[:objectId].nil? - params = params.merge(executionContextId: execution_id!) - end + target = if on + response = @page.command("DOM.resolveNode", nodeId: on.node_id) + { objectId: response.dig("object", "objectId") } + else + { executionContextId: execution_id! } + end + target[:returnByValue] = true if %i[raw none].include?(mode) response = @page.command("Runtime.callFunctionOn", - wait: wait, slowmoable: true, - **params.merge(functionDeclaration: expression, - arguments: prepare_args(arguments))) + wait: wait, timeout: timeout, slowmoable: true, + awaitPromise: true, + functionDeclaration: declaration, + arguments: prepare_args(arguments), + **target) handle_error(response) - response = response["result"] - handle ? handle_response(response) : response["value"] + result = response["result"] + + case mode + when :none then nil + when :raw then result["value"] + when :handle then handle_remote_object(result) + else handle_response(result) + end end end + def script_timeout(timeout) + (timeout || @page.timeout).to_f + end + # FIXME: We should have a central place to handle all type of errors def handle_error(response) result = response["result"] - return if result["subtype"] != "error" + details = response["exceptionDetails"] + return if details.nil? && result["subtype"] != "error" - case result["description"] - when /\AError: timed out promise/ - raise ScriptTimeoutError - else - raise JavaScriptError, response["exceptionDetails"] - end + description = result["description"] || + details&.dig("exception", "description") || + details&.dig("exception", "value") || + details&.fetch("text", nil) + + raise ScriptTimeoutError if description.to_s.include?(SCRIPT_TIMEOUT) || + description.to_s.include?(LEGACY_SCRIPT_TIMEOUT) + + raise JavaScriptError, details || { "text" => description } + end + + # Primitives and DOM nodes are already usable from Ruby, so only genuine + # browser-side objects are handed back as a {RemoteObject}. + def handle_remote_object(result) + return handle_response(result) if result["objectId"].nil? || result["subtype"] == "node" + + RemoteObject.new(@page, result) end def handle_response(response, check_cyclic: true) @@ -223,11 +548,14 @@ def handle_response(response, check_cyclic: true) def prepare_args(args) args.map do |arg| - if arg.is_a?(Node) + case arg + when Node resolved = @page.command("DOM.resolveNode", nodeId: arg.node_id) { objectId: resolved["object"]["objectId"] } - elsif arg.is_a?(Hash) && arg["objectId"] - { objectId: arg["objectId"] } + when RemoteObject + { objectId: arg.remote_id } + when Hash + arg["objectId"] ? { objectId: arg["objectId"] } : { value: arg } else { value: arg } end diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index 00803e2a..6faf76ff 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -202,9 +202,9 @@ def scroll_into_view # # @return [Boolean] def in_viewport?(of: nil) - function = <<~JS - function(element, scope) { - const rect = element.getBoundingClientRect(); + evaluate(<<~JS, scope: of) + function(scope) { + const rect = this.getBoundingClientRect(); const [height, width] = scope ? [scope.offsetHeight, scope.offsetWidth] : [window.innerHeight, window.innerWidth]; @@ -214,7 +214,6 @@ def in_viewport?(of: nil) rect.right <= width; } JS - page.evaluate_func(function, self, of) end # @@ -361,15 +360,14 @@ def attribute(name) # @return [Array] # def selected - function = <<~JS - function(element) { - if (element.nodeName.toLowerCase() !== 'select') { + evaluate(<<~JS) + function() { + if (this.nodeName.toLowerCase() !== 'select') { throw new Error('Element is not a element.'); } - const options = Array.from(element.options); - element.value = undefined; + const options = Array.from(this.options); + this.value = undefined; for (const option of options) { option.selected = values.some((value) => option[by] === value); - if (option.selected && !element.multiple) break; + if (option.selected && !this.multiple) break; } - element.dispatchEvent(new Event('input', { bubbles: true })); - element.dispatchEvent(new Event('change', { bubbles: true })); + this.dispatchEvent(new Event('input', { bubbles: true })); + this.dispatchEvent(new Event('change', { bubbles: true })); } JS - page.evaluate_func(function, self, values.flatten, by, on: self) end end # - # Evaluates the given JavaScript expression with `this` bound to the - # node. + # Evaluates JavaScript with `this` bound to the node, and returns the + # result serialized into Ruby. Takes the same script shapes and named + # arguments as {Frame::Runtime#evaluate}. # # @param [String] expression + # A JavaScript expression, or a function declaration. + # + # @param [Numeric, nil] timeout + # How long to wait for the script to settle, in seconds. + # + # @param [Hash, nil] args + # Arguments to pass, when a name would collide with a reserved keyword. + # + # @param [Hash] named + # Arguments to pass, becoming the function's parameters in order. # # @return [Object] # # @example # page.at_css("input").evaluate("this.value") + # page.at_css("input").evaluate("this.value + suffix", suffix: "!") + # page.at_css("input").evaluate("this.parentNode") # => Ferrum::Node + # + def evaluate(expression, *positional, timeout: nil, args: nil, **named) + page.evaluate_in(self, expression, positional, (args || {}).merge(named), + mode: :value, timeout: timeout) + end + + # + # Same as {#evaluate}, but returns a {RemoteObject} that stays in the + # browser instead of being serialized. + # + # @param (see #evaluate) # - def evaluate(expression) - page.evaluate_on(node: self, expression: expression) + # @return [RemoteObject, Node, Object] + # + def evaluate_handle(expression, *positional, timeout: nil, args: nil, **named) + page.evaluate_in(self, expression, positional, (args || {}).merge(named), + mode: :handle, timeout: timeout) + end + + # + # Runs JavaScript with `this` bound to the node, for its side effects. + # The script is used as a function body, so multiple statements need no + # wrapping. + # + # @param (see #evaluate) + # + # @return [Boolean] + # Always `true`. + # + # @example + # page.at_css("input").execute("this.value = text", text: "hello") + # + def execute(expression, *positional, timeout: nil, args: nil, **named) + page.evaluate_in(self, expression, positional, (args || {}).merge(named), + mode: :none, timeout: timeout) + true end # diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index 2b05e7b3..6a0f90c9 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -33,7 +33,8 @@ class Page delegate %i[at_css at_xpath css xpath wait_for_selector current_url current_title url title body doctype content= - execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func + execution_id execution_id! evaluate evaluate_handle execute evaluate_in + evaluate_on evaluate_async evaluate_func add_script_tag add_style_tag] => :main_frame delegate %i[base_url default_user_agent timeout timeout=] => :@options diff --git a/lib/ferrum/page/screenshot.rb b/lib/ferrum/page/screenshot.rb index cd64a2c8..e1af13e9 100644 --- a/lib/ferrum/page/screenshot.rb +++ b/lib/ferrum/page/screenshot.rb @@ -307,13 +307,14 @@ def viewport_area end def bounding_rect(selector) - rect = evaluate_async(%( - const rect = document - .querySelector(arguments[0]) - .getBoundingClientRect(); - const {x, y, width, height} = rect; - arguments[1]([x, y, width, height]) - ), timeout, selector) + rect = evaluate(<<~JS, selector: selector) + function(selector) { + const {x, y, width, height} = document + .querySelector(selector) + .getBoundingClientRect(); + return [x, y, width, height]; + } + JS { x: rect[0], y: rect[1], width: rect[2], height: rect[3] } end diff --git a/lib/ferrum/remote_object.rb b/lib/ferrum/remote_object.rb new file mode 100644 index 00000000..2b44bfa6 --- /dev/null +++ b/lib/ferrum/remote_object.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Ferrum + # + # An opaque reference to a JavaScript value living in the browser, returned + # by {Frame::Runtime#evaluate_handle}. Unlike {Frame::Runtime#evaluate}, + # which serializes the result into Ruby, a handle keeps the value in the + # page so it can be passed straight back into a later evaluation without a + # round trip through JSON. + # + # @example + # list = page.evaluate_handle("document.querySelectorAll('li')") + # page.evaluate("Array.from(nodes).map(n => n.textContent)", nodes: list) + # + class RemoteObject + # @return [String] the CDP remote object id. + attr_reader :remote_id + + # @return [String] the JavaScript type, e.g. `"object"` or `"function"`. + attr_reader :type + + # @return [String, nil] the JavaScript subtype, e.g. `"array"` or `"map"`. + attr_reader :subtype + + # @return [String, nil] the browser's own description of the value. + attr_reader :description + + # + # @param [Page] page + # The page the value belongs to. + # + # @param [Hash] result + # A CDP `Runtime.RemoteObject`. + # + def initialize(page, result) + @page = page + @remote_id = result["objectId"] + @type = result["type"] + @subtype = result["subtype"] + @description = result["description"] + end + + # + # Serializes the handle into a plain Ruby value, resolving nodes into + # {Node} objects the same way {Frame::Runtime#evaluate} does. + # + # @return [Object] + # + def value + @page.evaluate("value", value: self) + end + + # + # Releases the browser-side reference. The handle is unusable afterwards. + # + # @return [void] + # + def release + @page.command("Runtime.releaseObject", objectId: @remote_id) + nil + rescue Ferrum::BrowserError + nil + end + + # @return [String] + def inspect + %(#<#{self.class} @remote_id=#{@remote_id.inspect} @type=#{@type.inspect} ) + + %(@subtype=#{@subtype.inspect} @description=#{@description.inspect}>) + end + end +end diff --git a/lib/ferrum/utils/deprecate.rb b/lib/ferrum/utils/deprecate.rb new file mode 100644 index 00000000..217852f4 --- /dev/null +++ b/lib/ferrum/utils/deprecate.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Ferrum + module Utils + # + # Emits deprecation warnings for the legacy JavaScript evaluation API. + # + # Warnings are printed once per unique message and call site so that a + # deprecated call inside a loop doesn't flood the output. Set + # `FERRUM_DEPRECATION_WARNINGS=0` to silence them entirely, or + # `FERRUM_DEPRECATION_WARNINGS=raise` to turn them into errors while + # migrating a suite. + # + module Deprecate + MODE = ENV.fetch("FERRUM_DEPRECATION_WARNINGS", "warn") + + @seen = Concurrent::Set.new + + module_function + + # + # Warns that `old` is deprecated and should be replaced by `new`. + # + # @param [String] old + # The deprecated call, e.g. `"Ferrum::Frame#evaluate_async"`. + # + # @param [String] new + # What to use instead. + # + # @return [void] + # + def warn(old, new) + return if MODE == "0" || MODE == "false" + + location = caller_locations(2, 20)&.find do |frame| + !frame.path.include?("/lib/ferrum/") && !frame.path.end_with?("forwardable.rb") + end + message = "[Ferrum] DEPRECATION: #{old} is deprecated and will be removed in the next major " \ + "release. #{new}" + message += "\n called from #{location.path}:#{location.lineno}" if location + + raise Ferrum::Error, message if MODE == "raise" + return unless @seen.add?(message) + + Kernel.warn(message) + end + + # + # Forgets which warnings have already been printed. Only useful in tests. + # + # @return [void] + # + def reset! + @seen.clear + end + end + end +end diff --git a/sig/ferrum/frame/runtime.rbs b/sig/ferrum/frame/runtime.rbs index 90d9173c..a399913f 100644 --- a/sig/ferrum/frame/runtime.rbs +++ b/sig/ferrum/frame/runtime.rbs @@ -11,27 +11,65 @@ module Ferrum INTERMITTENT_SLEEP: untyped - def evaluate: (untyped expression, *untyped args) -> untyped + SCRIPT_TIMEOUT: ::String - def evaluate_async: (untyped expression, untyped wait, *untyped args) -> untyped + LEGACY_SCRIPT_TIMEOUT: ::String - def execute: (untyped expression, *untyped args) -> true + FUNCTION_DECLARATION: ::Regexp - def evaluate_func: (untyped expression, *untyped args, ?on: untyped?) -> untyped + ARROW_DECLARATION: ::Regexp - def evaluate_on: (node: untyped, expression: untyped, ?by_value: bool, ?wait: ::Integer) -> untyped + TRAILING_NOISE: ::Regexp + + JS_IDENTIFIER: ::Regexp + + PARAMETER_LISTS: ::Array[::Regexp] + + PUBLIC_NAMES: ::Hash[::Symbol, ::String] + + def evaluate: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> untyped + + def evaluate_handle: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> untyped + + def execute: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> true + + def evaluate_in: (Node node, untyped expression, ::Array[untyped] positional, ::Hash[untyped, untyped] args, ?mode: ::Symbol, ?timeout: Numeric?) -> untyped + + %a{deprecated} def evaluate_async: (untyped expression, untyped wait, *untyped args) -> untyped + + %a{deprecated} def evaluate_func: (untyped expression, *untyped args, ?on: untyped?) -> untyped + + %a{deprecated} def evaluate_on: (node: untyped, expression: untyped, ?by_value: bool, ?wait: ::Integer) -> untyped private - def call: (expression: untyped, ?arguments: untyped, ?on: untyped?, ?wait: ::Integer, ?handle: bool, **untyped options) -> untyped + def run: (untyped expression, ::Array[untyped] positional, ::Hash[untyped, untyped] args, ?mode: ::Symbol, ?timeout: Numeric?, ?on: Node?) -> untyped + + def arguments_for: (untyped expression, ::Array[untyped] positional, ::Hash[untyped, untyped] args, ::Symbol mode) -> [::Array[::String], ::Array[untyped]] + + def ordered_values: (untyped expression, ::Hash[untyped, untyped] hash) -> ::Array[untyped] + + def declaration?: (untyped expression) -> bool + + def declared_parameters: (untyped expression) -> ::Array[::String]? + + def validate_parameter!: (untyped key) -> ::String + + def wrap_expression: (untyped expression, ::Array[::String] params, Numeric timeout, returns: bool) -> ::String + + def call: (untyped declaration, ?::Array[untyped] arguments, ?on: Node?, ?wait: ::Integer, ?mode: ::Symbol, ?timeout: Numeric?) -> untyped + + def script_timeout: (Numeric? timeout) -> ::Float def handle_error: (untyped response) -> (nil | untyped) - def handle_response: (untyped response) -> untyped + def handle_remote_object: (untyped result) -> untyped + + def handle_response: (untyped response, ?check_cyclic: bool) -> untyped def prepare_args: (untyped args) -> untyped - def reduce_props: (untyped object_id, untyped to) { (untyped, untyped, untyped) -> untyped } -> (::Array[untyped] | untyped | untyped) + def reduce_props: (untyped object_id, untyped to, ?check_cyclic: bool) { (untyped, untyped, untyped) -> untyped } -> (::Array[untyped] | untyped | untyped) def cyclic?: (untyped object_id) -> untyped diff --git a/sig/ferrum/node.rbs b/sig/ferrum/node.rbs index 1e0fbb09..ae38c6d7 100644 --- a/sig/ferrum/node.rbs +++ b/sig/ferrum/node.rbs @@ -67,7 +67,11 @@ module Ferrum def select: (*untyped values, ?by: ::Symbol) -> untyped - def evaluate: (untyped expression) -> untyped + def evaluate: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> untyped + + def evaluate_handle: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> untyped + + def execute: (untyped expression, *untyped positional, ?timeout: Numeric?, ?args: ::Hash[untyped, untyped]?, **untyped named) -> true def ==: (untyped other) -> (false | untyped) diff --git a/sig/ferrum/remote_object.rbs b/sig/ferrum/remote_object.rbs new file mode 100644 index 00000000..f7ef162b --- /dev/null +++ b/sig/ferrum/remote_object.rbs @@ -0,0 +1,21 @@ +module Ferrum + class RemoteObject + attr_reader remote_id: ::String + + attr_reader type: ::String + + attr_reader subtype: ::String? + + attr_reader description: ::String? + + @page: Page + + def initialize: (Page page, untyped result) -> void + + def value: () -> untyped + + def release: () -> nil + + def inspect: () -> ::String + end +end diff --git a/sig/ferrum/utils/deprecate.rbs b/sig/ferrum/utils/deprecate.rbs new file mode 100644 index 00000000..f1435630 --- /dev/null +++ b/sig/ferrum/utils/deprecate.rbs @@ -0,0 +1,13 @@ +module Ferrum + module Utils + module Deprecate + MODE: ::String + + self.@seen: Concurrent::Set[::String] + + def self?.warn: (::String old, ::String new) -> void + + def self?.reset!: () -> void + end + end +end diff --git a/spec/frame/runtime_spec.rb b/spec/frame/runtime_spec.rb index 12504a63..b6473a45 100644 --- a/spec/frame/runtime_spec.rb +++ b/spec/frame/runtime_spec.rb @@ -238,6 +238,210 @@ end end + describe "#evaluate with named arguments" do + it "names arguments as function parameters" do + expect(browser.evaluate("a + b", a: 1, b: 2)).to eq(3) + end + + it "sends each argument separately so nodes stay live" do + browser.go_to("/index") + node = browser.at_xpath(".//a") + + expect(browser.evaluate("el.getAttribute(name)", el: node, name: "href")).to eq("js_redirect") + end + + it "binds by name when the script declares its own parameters" do + expect(browser.evaluate("function(a, b) { return a - b }", b: 1, a: 5)).to eq(4) + end + + it "falls back to insertion order when the names don't line up" do + expect(browser.evaluate("function(x, y) { return x - y }", a: 5, b: 1)).to eq(4) + end + + it "accepts arrow functions" do + expect(browser.evaluate("(a, b) => a * b", a: 3, b: 4)).to eq(12) + expect(browser.evaluate("a => a * 2", a: 21)).to eq(42) + end + + it "passes nil, hashes and arrays by value" do + expect(browser.evaluate("value", value: nil)).to be_nil + expect(browser.evaluate("value.foo", value: { foo: "bar" })).to eq("bar") + expect(browser.evaluate("value.length", value: [1, 2, 3])).to eq(3) + end + + it "accepts reserved names through args:" do + expect(browser.evaluate("timeout * 2", args: { timeout: 21 })).to eq(42) + end + + it "rejects names that aren't valid JavaScript identifiers" do + expect { browser.evaluate("a", args: { "not-an-identifier": 1 }) } + .to raise_error(ArgumentError, /not a valid JavaScript parameter name/) + end + + it "rejects mixing positional and named arguments" do + expect { browser.evaluate("a", 1, a: 2) } + .to raise_error(ArgumentError, /either positionally or by name/) + end + + it "still accepts deprecated positional arguments" do + expect(browser.evaluate("arguments[0] + arguments[1]", 1, 2)).to eq(3) + end + end + + describe "#evaluate with a function declaration" do + it "evaluates multiple statements" do + expect(browser.evaluate(<<~JS, c: 3)).to eq(6) + function(c) { + let a = 1; + let b = 2; + return a + b + c; + } + JS + end + + it "evaluates a named function declaration" do + expect(browser.evaluate("function sum(a, b) { return a + b }", a: 1, b: 2)).to eq(3) + end + + it "treats an immediately invoked function as an expression, not a declaration" do + expect(browser.evaluate(<<~JS)).to eq(3) + function() { + let a = 1; + return a + 2; + }(); + JS + expect(browser.evaluate("(function() { return 3 })()")).to eq(3) + expect(browser.evaluate("(() => 3)()")).to eq(3) + end + + it "ignores trailing comments and semicolons when detecting a declaration" do + expect(browser.evaluate("function(a) { return a } // adds nothing", a: 1)).to eq(1) + expect(browser.evaluate("function(a) { return a };", a: 1)).to eq(1) + end + + it "does not call a function that is merely the result of an expression" do + expect(browser.evaluate("new Function")).to eq({}) + expect(browser.evaluate("window.setTimeout")).to eq({}) + end + end + + describe "#evaluate with promises" do + it "awaits an expression" do + expect(browser.evaluate("await Promise.resolve(42)")).to eq(42) + end + + it "awaits a returned promise" do + expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(42), 100))")).to eq(42) + end + + it "awaits an async function declaration" do + expect(browser.evaluate("async function(a) { return await Promise.resolve(a) }", a: 7)).to eq(7) + end + + it "propagates a rejection" do + expect { browser.evaluate(%(await Promise.reject(new Error("nope")))) } + .to raise_error(Ferrum::JavaScriptError, /nope/) + end + + it "times out" do + expect { browser.evaluate("new Promise(() => {})", timeout: 1) } + .to raise_error(Ferrum::ScriptTimeoutError) + end + + it "honours a timeout longer than the page timeout" do + expect(browser.timeout).to be < 10 + expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(1), 100))", timeout: 10)).to eq(1) + end + end + + describe "#evaluate_handle" do + it "returns a handle that can be passed back in" do + browser.go_to("/index") + handle = browser.evaluate_handle("document.querySelectorAll('a')") + + expect(handle).to be_a(Ferrum::RemoteObject) + expect(browser.evaluate("nodes.length", nodes: handle)).to eq(browser.css("a").size) + end + + it "returns nodes as Node" do + browser.go_to("/index") + expect(browser.evaluate_handle("document.querySelector('a')")).to be_a(Ferrum::Node) + end + + it "returns primitives as-is" do + expect(browser.evaluate_handle("42")).to eq(42) + end + end + + describe "Ferrum::Node#evaluate" do + before { browser.go_to("/index") } + + it "binds this to the node" do + expect(browser.at_xpath(".//a").evaluate("this.getAttribute('href')")).to eq("js_redirect") + end + + it "accepts named arguments" do + expect(browser.at_xpath(".//a").evaluate("this.getAttribute(name)", name: "href")).to eq("js_redirect") + end + + it "resolves nodes instead of returning them by value" do + expect(browser.at_xpath(".//a").evaluate("this.parentNode")).to be_a(Ferrum::Node) + end + + it "runs statements with #execute" do + node = browser.at_xpath(".//a") + node.execute("this.dataset.touched = value", value: "yes") + + expect(node.evaluate("this.dataset.touched")).to eq("yes") + end + end + + describe "deprecations" do + around do |example| + Ferrum::Utils::Deprecate.reset! + example.run + Ferrum::Utils::Deprecate.reset! + end + + it "warns about #evaluate_async" do + expect { browser.evaluate_async("arguments[0](1)", 1) } + .to output(/DEPRECATION: .*#evaluate_async/).to_stderr + end + + it "warns about #evaluate_func" do + expect { browser.evaluate_func("function(a) { return a }", 1) } + .to output(/DEPRECATION: .*#evaluate_func/).to_stderr + end + + it "warns about #evaluate_on" do + browser.go_to("/index") + node = browser.at_xpath(".//a") + + expect { browser.evaluate_on(node: node, expression: "this.tagName") } + .to output(/DEPRECATION: .*#evaluate_on/).to_stderr + end + + it "warns about positional arguments" do + expect { browser.evaluate("arguments[0]", 1) } + .to output(/DEPRECATION: .*#evaluate with positional arguments/).to_stderr + end + + it "warns about positional arguments to #execute" do + expect { browser.execute("window.__x = arguments[0]", 1) } + .to output(/DEPRECATION: .*#execute with positional arguments/).to_stderr + end + + it "warns once per call site" do + original = $stderr + $stderr = StringIO.new + 3.times { browser.evaluate("arguments[0]", 1) } + output = $stderr.string + $stderr = original + + expect(output.scan("DEPRECATION").size).to eq(1) + end + end + describe "#add_script_tag" do it "adds by url" do browser.go_to diff --git a/spec/mouse_spec.rb b/spec/mouse_spec.rb index e823bbbc..2a3dcf45 100644 --- a/spec/mouse_spec.rb +++ b/spec/mouse_spec.rb @@ -60,12 +60,11 @@ it "splits into steps" do browser.go_to("/simple") browser.mouse.move(x: 100, y: 100) - browser.evaluate_async(<<~JS, browser.timeout) + browser.execute(<<~JS) window.result = []; document.addEventListener("mousemove", e => { window.result.push([e.clientX, e.clientY]); }); - arguments[0](); JS browser.mouse.move(x: 200, y: 300, steps: 5) @@ -82,14 +81,13 @@ it "sets buttons property" do browser.go_to("/simple") browser.mouse.move(x: 100, y: 100) - browser.evaluate_async(<<~JS, browser.timeout) + browser.execute(<<~JS) window.result = []; ["move", "up", "down"].forEach(type => document.addEventListener(`mouse${type}`, e => { window.result.push([type, e.clientX, e.clientY, e.buttons]); }) ); - arguments[0](); JS browser.mouse diff --git a/spec/unit/browser_spec.rb b/spec/unit/browser_spec.rb index f09112fc..90a8d378 100644 --- a/spec/unit/browser_spec.rb +++ b/spec/unit/browser_spec.rb @@ -19,7 +19,7 @@ def puts(*args) browser = Ferrum::Browser.new(logger: logger) browser.body file_log = File.read(file_path) - expect(file_log).to include("return document.documentElement?.outerHTML") + expect(file_log).to include("document.documentElement?.outerHTML") expect(file_log).to include("") ensure FileUtils.rm_f(file_path) @@ -32,7 +32,7 @@ def puts(*args) browser.body - expect(logger.string).to include("return document.documentElement?.outerHTML") + expect(logger.string).to include("document.documentElement?.outerHTML") expect(logger.string).to include("") ensure browser.quit diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb index 8cb85dbe..390f8ec6 100644 --- a/spec/worker_spec.rb +++ b/spec/worker_spec.rb @@ -57,7 +57,7 @@ def wait_for_target(&block) describe "service workers" do it "discovers registered service workers without connecting to them" do page.go_to - page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + page.evaluate(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) target = wait_for_target(&:service_worker?) @@ -68,7 +68,7 @@ def wait_for_target(&block) it "connects on demand through Context#attach_target, keeping it alive" do page.go_to - page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + page.evaluate(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) target = wait_for_target(&:service_worker?) browser.attach_target(target.id) From a73a157123e8125ffe7a25a20a9f3f5dfee64f02 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Mon, 24 Aug 2026 12:10:32 +0300 Subject: [PATCH 2/2] fix: test --- spec/frame/runtime_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/frame/runtime_spec.rb b/spec/frame/runtime_spec.rb index b6473a45..60e5214a 100644 --- a/spec/frame/runtime_spec.rb +++ b/spec/frame/runtime_spec.rb @@ -349,8 +349,8 @@ end it "honours a timeout longer than the page timeout" do - expect(browser.timeout).to be < 10 - expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(1), 100))", timeout: 10)).to eq(1) + script = "new Promise(resolve => setTimeout(() => resolve(1), 100))" + expect(browser.evaluate(script, timeout: browser.timeout + 5)).to eq(1) end end