lib/playwright/connection.rb



# frozen_string_literal: true

module Playwright
  # https://github.com/microsoft/playwright/blob/master/src/client/connection.ts
  # https://github.com/microsoft/playwright-python/blob/master/playwright/_impl/_connection.py
  # https://github.com/microsoft/playwright-java/blob/master/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java
  class Connection
    def initialize(transport)
      @transport = transport
      @transport.on_message_received do |message|
        dispatch(message)
      end
      @transport.on_driver_crashed do
        @callbacks.each_value do |callback|
          callback.reject(::Playwright::DriverCrashedError.new)
        end
        raise ::Playwright::DriverCrashedError.new
      end

      @objects = {} # Hash[ guid => ChannelOwner ]
      @waiting_for_object = {} # Hash[ guid => Promise<ChannelOwner> ]
      @callbacks = {} # Hash [ guid => Promise<ChannelOwner> ]
      @root_object = RootChannelOwner.new(self)
      @remote = false
      @tracing_count = 0
      @closed_error = nil
    end

    attr_reader :local_utils

    def mark_as_remote
      @remote = true
    end

    def remote?
      @remote
    end

    def async_run
      @transport.async_run
    end

    def stop
      @transport.stop
      cleanup
    end

    def cleanup(cause: nil)
      @closed_error = cause || TargetClosedError.new
      @callbacks.each_value do |callback|
        callback.reject(@closed_error)
      end
      @callbacks.clear
    end

    def initialize_playwright
      # Avoid Error: sdkLanguage: expected one of (javascript|python|java|csharp)
      # ref: https://github.com/microsoft/playwright/pull/18308
      # ref: https://github.com/YusukeIwaki/playwright-ruby-client/issues/228
      result = send_message_to_server('', 'initialize', { sdkLanguage: 'python' })
      ChannelOwners::Playwright.from(result['playwright'])
    end

    def set_in_tracing(value)
      if value
        @tracing_count += 1
      else
        @tracing_count -= 1
      end
    end

    def async_send_message_to_server(guid, method, params, metadata: nil)
      return if @closed_error

      callback = Concurrent::Promises.resolvable_future

      with_generated_id do |id|
        # register callback promise object first.
        # @see https://github.com/YusukeIwaki/puppeteer-ruby/pull/34
        @callbacks[id] = callback

        _metadata = {}
        frames = []
        if metadata
          frames = metadata[:stack]
          _metadata[:wallTime] = metadata[:wallTime]
          _metadata[:apiName] = metadata[:apiName]
          _metadata[:location] = metadata[:stack].first
          _metadata[:internal] = !metadata[:apiName]
        end
        _metadata.compact!

        message = {
          id: id,
          guid: guid,
          method: method,
          params: replace_channels_with_guids(params),
          metadata: _metadata,
        }

        begin
          @transport.send_message(message)
        rescue => err
          @callbacks.delete(id)
          callback.reject(err)
          raise unless err.is_a?(Transport::AlreadyDisconnectedError)
        end

        if @tracing_count > 0 && !frames.empty? && guid != 'localUtils'
          @local_utils.add_stack_to_tracing_no_reply(id, frames)
        end
      end

      callback
    end

    def send_message_to_server(guid, method, params, metadata: nil)
      async_send_message_to_server(guid, method, params, metadata: metadata).value!
    end

    private

    # ```usage
    # connection.with_generated_id do |id|
    #   # play with id
    # end
    # ````
    def with_generated_id(&block)
      @last_id ||= 0
      block.call(@last_id += 1)
    end

    # @param guid [String]
    # @param parent [Playwright::ChannelOwner]
    # @note This method should be used internally. Accessed via .send method from Playwright::ChannelOwner, so keep private!
    def update_object_from_channel_owner(guid, parent)
      @objects[guid] = parent
    end

    # @param guid [String]
    # @note This method should be used internally. Accessed via .send method from Playwright::ChannelOwner, so keep private!
    def delete_object_from_channel_owner(guid)
      @objects.delete(guid)
    end

    def dispatch(msg)
      return if @closed_error

      id = msg['id']
      if id
        callback = @callbacks.delete(id)

        unless callback
          raise "Cannot find command to respond: #{id}"
        end

        error = msg['error']
        if error && !msg['result']
          parsed_error = ::Playwright::Error.parse(error['error'])
          parsed_error.log = msg['log']
          callback.reject(parsed_error)
        else
          result = replace_guids_with_channels(msg['result'])
          callback.fulfill(result)
        end

        return
      end

      guid = msg['guid']
      method = msg['method']
      params = msg['params']

      if method == "__create__"
        remote_object = create_remote_object(
          parent_guid: guid,
          type: params["type"],
          guid: params["guid"],
          initializer: params["initializer"],
        )
        if remote_object.is_a?(ChannelOwners::LocalUtils)
          @local_utils = remote_object
        end
        return
      end

      object = @objects[guid]
      unless object
        raise "Cannot find object to \"#{method}\": #{guid}"
      end

      if method == "__adopt__"
        child = @objects[params["guid"]]
        unless child
          raise "Unknown new child: #{params['guid']}"
        end
        object.send(:adopt!, child)
        return
      end

      if method == "__dispose__"
        object.send(:dispose!, reason: params["reason"])
        return
      end

      object.channel.emit(method, replace_guids_with_channels(params))
    end

    def replace_channels_with_guids(payload)
      if payload.nil?
        return nil
      end

      if payload.is_a?(Array)
        return payload.map{ |pl| replace_channels_with_guids(pl) }
      end

      if payload.is_a?(Channel)
        return { guid: payload.guid }
      end

      if payload.is_a?(Hash)
        return payload.map { |k, v| [k, replace_channels_with_guids(v)] }.to_h
      end

      payload
    end

    def replace_guids_with_channels(payload)
      if payload.nil?
        return nil
      end

      if payload.is_a?(Array)
        return payload.map{ |pl| replace_guids_with_channels(pl) }
      end

      if payload.is_a?(Hash)
        guid = payload['guid']
        if guid && @objects[guid]
          return @objects[guid].channel
        end

        return payload.map { |k, v| [k, replace_guids_with_channels(v)] }.to_h
      end

      payload
    end

    # @return [Playwright::ChannelOwner|nil]
    def create_remote_object(parent_guid:, type:, guid:, initializer:)
      parent = @objects[parent_guid]
      unless parent
        raise "Cannot find parent object #{parent_guid} to create #{guid}"
      end
      initializer = replace_guids_with_channels(initializer)

      result =
        begin
          ChannelOwners.const_get(type).new(
            parent,
            type,
            guid,
            initializer,
          )
        rescue NameError
          raise "Missing type #{type}"
        end

      callback = @waiting_for_object.delete(guid)
      callback&.fulfill(result)

      result
    end
  end
end