Skip to content

Commit

Permalink
Impement basic CDP client generation code for Ruby bindings
Browse files Browse the repository at this point in the history
This commits allows to generate Ruby classes and modules from CDP
specification files (browser_protocol.json and js_protocol.json). The
generated code supports both commands and events:

  # Commands
  driver.devtools.page.navigate(url: 'http://google.com')
  driver.devtools.console.clear_messages

  # Events
  driver.devtools.page.enable
  driver.devtools.page.on(:load_event_fired) { |params| puts("Page loaded in #{params['timestamp']}") }
  driver.navigate.to(url: 'http://google.com')

The generated code attempts to be Rubyish meaning the following
conversions are applied:

* camelCase is converted into snake_case (clearMessages is clear_messages)
* command parameters are implemented with keywords arguments
* event names are implemented with snake_case symbols ('loadEventFired'
  is :load_event_fired)

Support for a custom return objects is not implemented yet, so the
commands and event callbacks now return simple hashes.

See https://chromedevtools.github.io/devtools-protocol/ for details on
what domains, commands and events exist in CDP.

The generated code will be placed into devtools/ directory during the
build to avoid storing all the generated files with the source code and
exclude them from `//rb:lint` task. Likewise, generator files won't be
added to the gem releases.

The generation is implemented using a new task `//rb:cdp` which simply
delegates to `CDPClientGenerator#call`. The task uses specification files
provided by Java bindings code. It should be replaced by generation from
Java Bazel task but it's yet to be implemented.

There is also a new task `//rb:devtools` which depends on `//rb:cdp` and
then copies generated files for tests/docs/gem release.
  • Loading branch information
p0deje committed Mar 20, 2020
1 parent 506c497 commit 9e14610
Show file tree
Hide file tree
Showing 7 changed files with 328 additions and 20 deletions.
3 changes: 3 additions & 0 deletions rake_tasks/crazy_fun/mappings/ruby_mappings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require_relative 'ruby_mappings/ruby_library'
require_relative 'ruby_mappings/ruby_linter'
require_relative 'ruby_mappings/ruby_test'
require_relative 'ruby_mappings/ruby_class_call'

module CrazyFun
module Mappings
Expand All @@ -25,6 +26,8 @@ def add_all(fun)

fun.add_mapping "rubydocs", RubyDocs.new
fun.add_mapping "rubygem", RubyGem.new

fun.add_mapping "ruby_class_call", RubyClassCall.new
end
end
end
Expand Down
45 changes: 45 additions & 0 deletions rake_tasks/crazy_fun/mappings/ruby_mappings/ruby_class_call.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module CrazyFun
module Mappings
class RubyMappings
class RubyClassCall < RubyLibrary
def handle(_fun, dir, args)
desc "Call class #{args[:name]} in build/#{dir}"
task_name = task_name(dir, args[:name])

t = task task_name do
puts "Preparing: #{task_name} in #{build_dir}/#{dir}"
copy_sources dir, args[:srcs]
copy_resources dir, args[:resources], build_dir if args[:resources]
require_source build_dir, args[:require]
create_output_dir build_dir, args[:output_dir]
call_class args[:klass]
remove_sources args[:srcs]
end

add_dependencies(t, dir, args[:deps])
add_dependencies(t, dir, args[:resources])
end

def require_source(dir, src)
require File.join(dir, src)
end

def create_output_dir(root_dir, output_dir)
mkdir_p File.join(root_dir, output_dir)
end

def call_class(klass)
Object.const_get(klass).new.call
end

def remove_sources(globs)
globs.each do |glob|
Dir[File.join(build_dir, 'rb', glob)].each do |file|
rm_rf file
end
end
end
end
end
end
end
33 changes: 30 additions & 3 deletions rb/build.desc
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ rubygem(
deps = [
"//rb:chrome",
"//rb:common",
"//rb:support",
"//rb:devtools",
"//rb:edge",
"//rb:edge-chrome",
"//rb:firefox",
"//rb:ie",
"//rb:remote",
"//rb:safari"
"//rb:safari",
"//rb:support"
]
)

Expand Down Expand Up @@ -42,7 +43,7 @@ ruby_library(name = "common",
"README.md"
],
resources = [
{ "../LICENSE" : "rb/LICENSE" },
{ "../LICENSE": "rb/LICENSE" },
{ "//javascript/webdriver/atoms:get-attribute": "rb/lib/selenium/webdriver/atoms/getAttribute.js"},
{ "//javascript/atoms/fragments:find-elements": "rb/lib/selenium/webdriver/atoms/findElements.js"},
{ "//javascript/atoms/fragments:is-displayed": "rb/lib/selenium/webdriver/atoms/isDisplayed.js"}
Expand Down Expand Up @@ -261,6 +262,17 @@ ruby_test(name = "safari-preview",
deps = [":safari"]
)

ruby_library(name = "devtools",
srcs = [
"lib/selenium/webdriver/devtools/**/*.rb",
"lib/selenium/webdriver/devtools.rb",
],
deps = [
":common",
":cdp"
]
)

ruby_test(name = "unit",
srcs = [
"spec/unit/selenium/webdriver/**/*_spec.rb",
Expand All @@ -279,6 +291,21 @@ ruby_test(name = "unit",
]
)

ruby_class_call(name = "cdp",
klass = "Selenium::WebDriver::Support::CDPClientGenerator",
require = "rb/lib/selenium/webdriver/support/cdp_client_generator",
output_dir = "rb/lib/selenium/webdriver/devtools",
srcs = [
"lib/selenium/webdriver/support/cdp",
"lib/selenium/webdriver/support/cdp/**/*",
"lib/selenium/webdriver/support/cdp_client_generator.rb"
],
resources = [
{ "../java/client/src/org/openqa/selenium/devtools/browser_protocol.json": "rb/lib/selenium/webdriver/support/cdp/browser_protocol.json" },
{ "../java/client/src/org/openqa/selenium/devtools/js_protocol.json": "rb/lib/selenium/webdriver/support/cdp/js_protocol.json" }
]
)

ruby_lint(name = "lint",
srcs = [
"lib/**/*.rb",
Expand Down
82 changes: 65 additions & 17 deletions rb/lib/selenium/webdriver/devtools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,54 +17,102 @@
# specific language governing permissions and limitations
# under the License.

Dir["#{__dir__}/devtools/*"].each { |f| require f }

module Selenium
module WebDriver
class DevTools

def initialize(url)
@messages = []
@uri = URI("http://#{url}")

process_handshake
attach_socket_listener

target.attach_to_target(target_id: page_target['id'])
target.set_auto_attach(auto_attach: true, wait_for_debugger_on_start: false)
end

def callbacks
@callbacks ||= Hash.new { |callbacks, event| callbacks[event] = [] }
end

def send(method, **params)
data = JSON.generate(id: next_id, method: method, params: params)
def send_cmd(method, **params)
id = next_id
data = JSON.generate(id: id, method: method, params: params.reject { |_, v| v.nil? })

out_frame = WebSocket::Frame::Outgoing::Client.new(version: ws.version, data: data, type: 'text')
socket.write(out_frame.to_s)

in_frame = WebSocket::Frame::Incoming::Client.new(version: ws.version)
in_frame << socket.readpartial(4096)
JSON.parse(in_frame.next.to_s)
end
message = wait.until do
@messages.find { |m| m['id'] == id }
end

private
raise Error::WebDriverError, error_message(message['error']) if message['error']

def next_id
@id ||= 0
@id += 1
message
end

private

def process_handshake
socket.write(ws.to_s)
socket.print(ws.to_s)
ws << socket.readpartial(1024)
end

def attach_socket_listener
socket_listener = Thread.new do
until socket.eof?
incoming_frame << socket.readpartial(1024)

while (frame = incoming_frame.next)
message = JSON.parse(frame.to_s)
@messages << message
next unless message['method']

callbacks[message['method']].each do |callback|
callback.call(message['params'])
end
end
end
end
socket_listener.abort_on_exception = true
end

def incoming_frame
@incoming_frame ||= WebSocket::Frame::Incoming::Client.new(version: ws.version)
end

def wait
@wait ||= Wait.new(timeout: 10, interval: 0.1)
end

def socket
@socket ||= TCPSocket.new(ws.host, ws.port)
end

def ws
@ws ||= WebSocket::Handshake::Client.new(url: ws_url)
@ws ||= WebSocket::Handshake::Client.new(url: page_target['webSocketDebuggerUrl'])
end

def ws_url
@ws_url ||= begin
urls = JSON.parse(Net::HTTP.get(@uri.hostname, '/json', @uri.port))
page = urls.find { |u| u['type'] == 'page' }
page['webSocketDebuggerUrl']
def page_target
@page_target ||= begin
response = Net::HTTP.get(@uri.hostname, '/json', @uri.port)
targets = JSON.parse(response)
targets.find { |target| target['type'] == 'page' }
end
end

def next_id
@id ||= 0
@id += 1
end

def error_message(error)
[error['code'], error['message'], error['data']].join(': ')
end

end # DevTools
end # WebDriver
end # Selenium
67 changes: 67 additions & 0 deletions rb/lib/selenium/webdriver/support/cdp/domain.rb.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

# This file is automatically generated. Any changes will be lost!
module Selenium
module WebDriver
class DevTools
def <%= h.snake_case(domain[:domain]) %>
@<%= h.snake_case(domain[:domain]) %> ||= <%= domain[:domain] %>.new(self)
end

class <%= domain[:domain] %>
<% if domain[:events] %>
EVENTS = {
<% domain[:events].each do |event| %>
<%= h.snake_case(event[:name]) %>: '<%= event[:name] %>',
<% end %>
}
<% end %>

def initialize(devtools)
@devtools = devtools
end

def on(event, &block)
event = EVENTS[event] if event.is_a?(Symbol)
@devtools.callbacks["<%= domain[:domain] %>.#{event}"] << block
end

<% domain[:commands].each do |command| %>
<% if command[:parameters] %>
def <%= h.snake_case(command[:name]) %>(<%= h.kwargs(command[:parameters]) %>)
<% else %>
def <%= h.snake_case(command[:name]) %>
<% end %>
<% if command[:parameters] %>
@devtools.send_cmd('<%= domain[:domain] %>.<%= command[:name] %>',
<% until command[:parameters].empty? %>
<% parameter = command[:parameters].shift %>
<%= parameter[:name] %>: <%= h.snake_case(parameter[:name]) %><%= command[:parameters].empty? ? ')' : ',' %>
<% end %>
<% else %>
@devtools.send_cmd('<%= domain[:domain] %>.<%= command[:name] %>')
<% end %>
end

<% end %>
end # <%= domain[:domain] %>
end # DevTools
end # WebDriver
end # Selenium
77 changes: 77 additions & 0 deletions rb/lib/selenium/webdriver/support/cdp_client_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

require 'erb'
require 'json'

module Selenium
module WebDriver
module Support
class CDPClientGenerator
DEVTOOLS_DIR = File.expand_path('../devtools', __dir__)
BROWSER_PROTOCOL_PATH = File.expand_path('cdp/browser_protocol.json', __dir__)
JS_PROTOCOL_PATH = File.expand_path('cdp/js_protocol.json', __dir__)
TEMPLATE_PATH = File.expand_path('cdp/domain.rb.erb', __dir__)

RESERVED_KEYWORDS = %w[end].freeze

def initialize
@browser_protocol = JSON.parse(File.read(BROWSER_PROTOCOL_PATH), symbolize_names: true)
@js_protocol = JSON.parse(File.read(JS_PROTOCOL_PATH), symbolize_names: true)
@template = ERB.new(File.read(TEMPLATE_PATH))
end

def call
@browser_protocol[:domains].each(&method(:process_domain))
@js_protocol[:domains].each(&method(:process_domain))
end

def process_domain(domain)
result = @template.result_with_hash(domain: domain, h: self)
filename = File.join(DEVTOOLS_DIR, "#{snake_case(domain[:domain])}.rb")
File.write(filename, remove_empty_lines(result))
end

def snake_case(string)
name = string.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
# Certain CDP parameters conflict with Ruby keywords
# so we prefix the name with underscore.
name = "_#{name}" if RESERVED_KEYWORDS.include?(name)

name
end

def kwargs(parameters)
parameters = parameters.map do |parameter|
if parameter[:optional]
"#{snake_case(parameter[:name])}: nil"
else
"#{snake_case(parameter[:name])}:"
end
end
parameters.join(', ')
end

def remove_empty_lines(string)
string.split("\n").reject { |l| l =~ /^\s+$/ }.join("\n")
end
end
end
end
end
Loading

0 comments on commit 9e14610

Please sign in to comment.