lib/ferrum/frame/runtime.rb



# frozen_string_literal: true

require "singleton"

module Ferrum
  class CyclicObject
    include Singleton

    def inspect
      %(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
    end
  end

  class Frame
    module Runtime
      INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
      INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f

      #
      # Evaluate and return result for given JS expression.
      #
      # @param [String] expression
      #   The JavaScript to evaluate.
      #
      # @param [Array] args
      #   Additional arguments to pass to the JavaScript code.
      #
      # @example
      #   browser.evaluate("[window.scrollX, window.scrollY]")
      #
      def evaluate(expression, *args)
        expression = format("function() { return %s }", expression)
        call(expression: expression, arguments: args)
      end

      #
      # Evaluate asynchronous expression and return result.
      #
      # @param [String] expression
      #   The JavaScript to evaluate.
      #
      # @param [Integer] wait
      #   How long we should wait for Promise to resolve or reject.
      #
      # @param [Array] args
      #   Additional arguments to pass to the JavaScript code.
      #
      # @example
      #   browser.evaluate_async(%(arguments[0]({foo: "bar"})), 5) # => { "foo" => "bar" }
      #
      def evaluate_async(expression, wait, *args)
        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);
                %s
              } catch(error) {
                __r(error);
              }
            });
          }
        JS

        expression = format(template, wait * 1000, expression)
        call(expression: expression, arguments: args, awaitPromise: true)
      end

      #
      # Execute expression. Doesn't return the result.
      #
      # @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

      def evaluate_func(expression, *args, on: nil)
        call(expression: expression, arguments: args, on: on)
      end

      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)
      end

      private

      def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
        errors = [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

          response = @page.command("Runtime.callFunctionOn",
                                   wait: wait, slowmoable: true,
                                   **params.merge(functionDeclaration: expression,
                                                  arguments: prepare_args(arguments)))
          handle_error(response)
          response = response["result"]

          handle ? handle_response(response) : response["value"]
        end
      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"

        case result["description"]
        when /\AError: timed out promise/
          raise ScriptTimeoutError
        else
          raise JavaScriptError.new(result, response.dig("exceptionDetails", "stackTrace"))
        end
      end

      def handle_response(response)
        case response["type"]
        when "boolean", "number", "string"
          response["value"]
        when "undefined"
          nil
        when "function"
          {}
        when "object"
          object_id = response["objectId"]

          case response["subtype"]
          when "node"
            # We cannot store object_id in the node because page can be reloaded
            # and node destroyed so we need to retrieve it each time for given id.
            # Though we can try to subscribe to `DOM.childNodeRemoved` and
            # `DOM.childNodeInserted` in the future.
            node_id = @page.command("DOM.requestNode", objectId: object_id)["nodeId"]
            description = @page.command("DOM.describeNode", nodeId: node_id)["node"]
            Node.new(self, @page.target_id, node_id, description)
          when "array"
            reduce_props(object_id, []) do |memo, key, value|
              next(memo) unless Integer(key, exception: false)

              value = value["objectId"] ? handle_response(value) : value["value"]
              memo.insert(key.to_i, value)
            end.compact
          when "date"
            response["description"]
          when "null"
            nil
          else
            reduce_props(object_id, {}) do |memo, key, value|
              value = value["objectId"] ? handle_response(value) : value["value"]
              memo.merge(key => value)
            end
          end
        end
      end

      def prepare_args(args)
        args.map do |arg|
          if arg.is_a?(Node)
            resolved = @page.command("DOM.resolveNode", nodeId: arg.node_id)
            { objectId: resolved["object"]["objectId"] }
          elsif arg.is_a?(Hash) && arg["objectId"]
            { objectId: arg["objectId"] }
          else
            { value: arg }
          end
        end
      end

      def reduce_props(object_id, to)
        if cyclic?(object_id).dig("result", "value")
          to.is_a?(Array) ? [cyclic_object] : cyclic_object
        else
          props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
          props["result"].reduce(to) do |memo, prop|
            next(memo) unless prop["enumerable"]

            yield(memo, prop["name"], prop["value"])
          end
        end
      end

      def cyclic?(object_id)
        @page.command(
          "Runtime.callFunctionOn",
          objectId: object_id,
          returnByValue: true,
          functionDeclaration: <<~JS
            function() {
              if (Array.isArray(this) &&
                  this.every(e => e instanceof Node)) {
                return false;
              }

              function detectCycle(obj, seen) {
                if (typeof obj === "object") {
                  if (seen.indexOf(obj) !== -1) {
                    return true;
                  }
                  for (let key in obj) {
                    if (obj.hasOwnProperty(key) && detectCycle(obj[key], seen.concat([obj]))) {
                      return true;
                    }
                  }
                }

                return false;
              }

              return detectCycle(this, []);
            }
          JS
        )
      end

      def cyclic_object
        CyclicObject.instance
      end
    end
  end
end