cuprite 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,27 @@
1
1
  module Capybara::Cuprite
2
2
  class Browser
3
3
  module Input
4
+ KEYS = JSON.parse(File.read(File.expand_path("../input.json", __FILE__)))
5
+ MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2, "meta" => 4, "command" => 4, "shift" => 8 }
6
+ KEYS_MAPPING = {
7
+ cancel: "Cancel", help: "Help", backspace: "Backspace", tab: "Tab",
8
+ clear: "Clear", return: "Enter", enter: "Enter", shift: "Shift",
9
+ ctrl: "Control", control: "Control", alt: "Alt", pause: "Pause",
10
+ escape: "Escape", space: "Space", pageup: "PageUp", page_up: "PageUp",
11
+ pagedown: "PageDown", page_down: "PageDown", end: "End", home: "Home",
12
+ left: "ArrowLeft", up: "ArrowUp", right: "ArrowRight",
13
+ down: "ArrowDown", insert: "Insert", delete: "Delete",
14
+ semicolon: "Semicolon", equals: "Equal", numpad0: "Numpad0",
15
+ numpad1: "Numpad1", numpad2: "Numpad2", numpad3: "Numpad3",
16
+ numpad4: "Numpad4", numpad5: "Numpad5", numpad6: "Numpad6",
17
+ numpad7: "Numpad7", numpad8: "Numpad8", numpad9: "Numpad9",
18
+ multiply: "NumpadMultiply", add: "NumpadAdd",
19
+ separator: "NumpadDecimal", subtract: "NumpadSubtract",
20
+ decimal: "NumpadDecimal", divide: "NumpadDivide", f1: "F1", f2: "F2",
21
+ f3: "F3", f4: "F4", f5: "F5", f6: "F6", f7: "F7", f8: "F8", f9: "F9",
22
+ f10: "F10", f11: "F11", f12: "F12", meta: "Meta", command: "Meta",
23
+ }
24
+
4
25
  def click(node, keys = [], offset = {})
5
26
  x, y, modifiers = prepare_before_click(__method__, node, keys, offset)
6
27
  command("Input.dispatchMouseEvent", type: "mousePressed", modifiers: modifiers, button: "left", x: x, y: y, clickCount: 1)
@@ -59,21 +80,66 @@ module Capybara::Cuprite
59
80
  end
60
81
 
61
82
  def send_keys(node, keys)
83
+ keys = normalize_keys(Array(keys))
84
+
62
85
  click(node) if !evaluate_on(node: node, expr: %(_cuprite.containsSelection(this)))
63
86
 
64
- keys.first.each_char do |char|
65
- # Check puppeteer Input.js and USKeyboardLayout.js also send_keys and modifiers from poltergeist.
66
- if /\n/.match?(char)
67
- command("Input.insertText", text: char)
68
- # command("Input.dispatchKeyEvent", type: "keyDown", code: "Enter", key: "Enter", text: "\r")
69
- # command("Input.dispatchKeyEvent", type: "keyUp", code: "Enter", key: "Enter")
87
+ keys.each do |key|
88
+ type = key[:text] ? "keyDown" : "rawKeyDown"
89
+ command("Input.dispatchKeyEvent", type: type, **key)
90
+ command("Input.dispatchKeyEvent", type: "keyUp", **key)
91
+ end
92
+ end
93
+
94
+ def normalize_keys(keys, pressed_keys = [], memo = [])
95
+ case keys
96
+ when Array
97
+ pressed_keys.push([])
98
+ memo += combine_strings(keys).map { |k| normalize_keys(k, pressed_keys, memo) }
99
+ pressed_keys.pop
100
+ memo.flatten.compact
101
+ when Symbol
102
+ key = keys.to_s.downcase
103
+
104
+ if MODIFIERS.keys.include?(key)
105
+ pressed_keys.last.push(key)
106
+ nil
70
107
  else
71
- command("Input.dispatchKeyEvent", type: "keyDown", text: char)
72
- command("Input.dispatchKeyEvent", type: "keyUp", text: char)
108
+ _key = KEYS.fetch(KEYS_MAPPING[key.to_sym] || key.to_sym)
109
+ _key[:modifiers] = pressed_keys.flatten.map { |k| MODIFIERS[k] }.reduce(0, :|)
110
+ to_options(_key)
111
+ end
112
+ when String
113
+ pressed = pressed_keys.flatten
114
+ keys.each_char.map do |char|
115
+ if pressed.empty?
116
+ key = KEYS[char] || {}
117
+ key = key.merge(text: char, unmodifiedText: char)
118
+ [to_options(key)]
119
+ else
120
+ key = KEYS[char] || {}
121
+ text = pressed == ["shift"] ? char.upcase : char
122
+ key = key.merge(
123
+ text: text,
124
+ unmodifiedText: text,
125
+ isKeypad: key["location"] == 3,
126
+ modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|),
127
+ )
128
+
129
+ modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) }
130
+ modifiers + [to_options(key)]
131
+ end.flatten
73
132
  end
74
133
  end
75
134
  end
76
135
 
136
+ def combine_strings(keys)
137
+ keys
138
+ .chunk { |k| k.is_a?(String) }
139
+ .map { |s, k| s ? [k.reduce(&:+)] : k }
140
+ .reduce(&:+)
141
+ end
142
+
77
143
  private
78
144
 
79
145
  def prepare_before_click(name, node, keys, offset)
@@ -81,8 +147,7 @@ module Capybara::Cuprite
81
147
  x, y = calculate_quads(node, offset[:x], offset[:y])
82
148
  evaluate_on(node: node, expr: "_cuprite.mouseEventTest(this, '#{name}', #{x}, #{y})")
83
149
 
84
- click_modifiers = { alt: 1, ctrl: 2, control: 2, meta: 4, command: 4, shift: 8 }
85
- modifiers = keys.map { |k| click_modifiers[k.to_sym] }.compact.reduce(0, :|)
150
+ modifiers = keys.map { |k| MODIFIERS[k.to_s] }.compact.reduce(0, :|)
86
151
 
87
152
  command("Input.dispatchMouseEvent", type: "mouseMoved", x: x, y: y)
88
153
 
@@ -117,6 +182,10 @@ module Capybara::Cuprite
117
182
  {x: quad[6], y: quad[7]}]
118
183
  end.first
119
184
  end
185
+
186
+ def to_options(hash)
187
+ hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
188
+ end
120
189
  end
121
190
  end
122
191
  end
@@ -10,6 +10,10 @@ const EVENTS = {
10
10
  }
11
11
 
12
12
  class Cuprite {
13
+ constructor() {
14
+ this._json = JSON; // In case someone overrides it like mootools
15
+ }
16
+
13
17
  find(method, selector, within = document) {
14
18
  try {
15
19
  let results = [];
@@ -386,7 +390,7 @@ class Cuprite {
386
390
  attrs[attr.name] = attr.value.replace("\n", "\\n");
387
391
  }
388
392
 
389
- return JSON.stringify(attrs);
393
+ return this._json.stringify(attrs);
390
394
  }
391
395
 
392
396
  getAttribute(node, name) {
@@ -435,6 +439,15 @@ class Cuprite {
435
439
 
436
440
  return node.contains(selectedNode);
437
441
  }
442
+
443
+ isCyclic(object) {
444
+ try {
445
+ this._json.stringify(object);
446
+ return false;
447
+ } catch (e) {
448
+ return true;
449
+ }
450
+ }
438
451
  }
439
452
 
440
453
  window._cuprite = new Cuprite;
@@ -43,6 +43,8 @@ module Capybara::Cuprite
43
43
  @frames = {}
44
44
  @waiting_frames ||= Set.new
45
45
  @frame_stack = []
46
+ @accept_modal = []
47
+ @modal_messages = []
46
48
 
47
49
  begin
48
50
  @session_id = @browser.command("Target.attachToTarget", targetId: @target_id)["sessionId"]
@@ -72,7 +74,7 @@ module Capybara::Cuprite
72
74
  options = { url: url }
73
75
  options.merge!(referrer: referrer) if referrer
74
76
  response = command("Page.navigate", **options)
75
- if response["errorText"] == "net::ERR_NAME_RESOLUTION_FAILED"
77
+ if %w[net::ERR_NAME_NOT_RESOLVED net::ERR_NAME_RESOLUTION_FAILED].include?(response["errorText"])
76
78
  raise StatusFailError, "url" => url
77
79
  end
78
80
  response["frameId"]
@@ -123,6 +125,49 @@ module Capybara::Cuprite
123
125
  go(1)
124
126
  end
125
127
 
128
+ def accept_confirm
129
+ @accept_modal << true
130
+ end
131
+
132
+ def dismiss_confirm
133
+ @accept_modal << false
134
+ end
135
+
136
+ def accept_prompt(modal_response)
137
+ @accept_modal << true
138
+ @modal_response = modal_response
139
+ end
140
+
141
+ def dismiss_prompt
142
+ @accept_modal << false
143
+ end
144
+
145
+ def find_modal(options)
146
+ start_time = Time.now
147
+ timeout_sec = options.fetch(:wait) { session_wait_time }
148
+ expect_text = options[:text]
149
+ expect_regexp = expect_text.is_a?(Regexp) ? expect_text : Regexp.escape(expect_text.to_s)
150
+ not_found_msg = "Unable to find modal dialog"
151
+ not_found_msg += " with #{expect_text}" if expect_text
152
+
153
+ begin
154
+ modal_text = @modal_messages.shift
155
+ raise Capybara::ModalNotFound if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp))
156
+ rescue Capybara::ModalNotFound => e
157
+ raise e, not_found_msg if (Time.now - start_time) >= timeout_sec
158
+ sleep(0.05)
159
+ retry
160
+ end
161
+
162
+ modal_text
163
+ end
164
+
165
+ def reset_modals
166
+ @accept_modal = []
167
+ @modal_response = nil
168
+ @modal_messages = []
169
+ end
170
+
126
171
  def command(*args)
127
172
  id = nil
128
173
 
@@ -151,6 +196,19 @@ module Capybara::Cuprite
151
196
  end
152
197
  end
153
198
 
199
+ @client.subscribe("Page.javascriptDialogOpening") do |params|
200
+ accept_modal = @accept_modal.last
201
+ if accept_modal == true || accept_modal == false
202
+ @accept_modal.pop
203
+ @modal_messages << params["message"]
204
+ options = { accept: accept_modal }
205
+ default = params["defaultPrompt"]
206
+ response = @modal_response || params["defaultPrompt"]
207
+ options.merge!(promptText: response) if response
208
+ @client.command("Page.handleJavaScriptDialog", **options)
209
+ end
210
+ end
211
+
154
212
  @client.subscribe("Page.windowOpen") do
155
213
  @browser.targets.refresh
156
214
  @mutex.try_lock
@@ -7,7 +7,7 @@ module Capybara::Cuprite
7
7
  class Process
8
8
  KILL_TIMEOUT = 2
9
9
 
10
- BROWSER_PATH = ENV.fetch("BROWSER_PATH", "chrome")
10
+ BROWSER_PATH = ENV["BROWSER_PATH"]
11
11
  BROWSER_HOST = "127.0.0.1"
12
12
  BROWSER_PORT = "0"
13
13
 
@@ -25,7 +25,7 @@ module Capybara::Cuprite
25
25
  "disable-web-security" => nil,
26
26
  }.freeze
27
27
 
28
- attr_reader :host, :port, :ws_url, :pid, :options
28
+ attr_reader :host, :port, :ws_url, :pid, :path, :options
29
29
 
30
30
  def self.start(*args)
31
31
  new(*args).tap(&:start)
@@ -54,15 +54,8 @@ module Capybara::Cuprite
54
54
 
55
55
  def initialize(options)
56
56
  @options = options.fetch(:browser, {})
57
- exe = options[:path] || BROWSER_PATH
58
- @path = Cliver.detect(exe)
59
57
 
60
- unless @path
61
- message = "Could not find an executable `#{exe}`. Try to make it " \
62
- "available on the PATH or set environment varible for " \
63
- "example BROWSER_PATH=\"/Applications/Chromium.app/Contents/MacOS/Chromium\""
64
- raise Cliver::Dependency::NotFound.new(message)
65
- end
58
+ detect_browser_path
66
59
 
67
60
  window_size = options.fetch(:window_size, [1024, 768])
68
61
  @options = @options.merge("window-size" => window_size.join(","))
@@ -106,6 +99,24 @@ module Capybara::Cuprite
106
99
  start
107
100
  end
108
101
 
102
+ def detect_browser_path
103
+ exe = @options[:path] || BROWSER_PATH
104
+ if RUBY_PLATFORM.include?('darwin')
105
+ exe ||= "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
106
+ @path = exe if File.exist?(exe)
107
+ else
108
+ exe ||= "chrome"
109
+ @path = Cliver.detect(exe) || Cliver.detect("google-chrome")
110
+ end
111
+
112
+ unless @path
113
+ message = "Could not find an executable `#{exe}`. Try to make it " \
114
+ "available on the PATH or set environment varible for " \
115
+ "example BROWSER_PATH=\"/Applications/Chromium.app/Contents/MacOS/Chromium\""
116
+ raise Cliver::Dependency::NotFound.new(message)
117
+ end
118
+ end
119
+
109
120
  private
110
121
 
111
122
  def redirect_stdout(write_io)
@@ -130,26 +141,28 @@ module Capybara::Cuprite
130
141
  @pid = nil
131
142
  end
132
143
 
133
- def parse_ws_url(read_io)
144
+ def parse_ws_url(read_io, timeout = 1)
134
145
  output = ""
135
- attempts = 3
146
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
147
+ max_time = start + timeout
136
148
  regexp = /DevTools listening on (ws:\/\/.*)/
137
- loop do
149
+ while (now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) < max_time
138
150
  begin
139
151
  output += read_io.read_nonblock(512)
140
152
  rescue IO::WaitReadable
141
- attempts -= 1
142
- break if attempts <= 0
143
- IO.select([read_io], nil, nil, 1)
144
- retry
153
+ IO.select([read_io], nil, nil, max_time - now)
154
+ else
155
+ if output.match(regexp)
156
+ @ws_url = Addressable::URI.parse(output.match(regexp)[1])
157
+ @host = @ws_url.host
158
+ @port = @ws_url.port
159
+ break
160
+ end
145
161
  end
162
+ end
146
163
 
147
- if output.match?(regexp)
148
- @ws_url = Addressable::URI.parse(output.match(regexp)[1])
149
- @host = @ws_url.host
150
- @port = @ws_url.port
151
- break
152
- end
164
+ unless @ws_url
165
+ raise "Chrome process did not produce websocket url within #{timeout} seconds"
153
166
  end
154
167
  end
155
168
 
@@ -108,7 +108,7 @@ module Capybara::Cuprite
108
108
  end
109
109
  end
110
110
 
111
- def handle(response, cleanup = true)
111
+ def handle(response)
112
112
  case response["type"]
113
113
  when "boolean", "number", "string"
114
114
  response["value"]
@@ -117,15 +117,17 @@ module Capybara::Cuprite
117
117
  when "function"
118
118
  {}
119
119
  when "object"
120
+ object_id = response["objectId"]
121
+
120
122
  case response["subtype"]
121
123
  when "node"
122
- node_id = command("DOM.requestNode", objectId: response["objectId"])["nodeId"]
123
- node = command("DOM.describeNode", nodeId: node_id)["node"]
124
- { "target_id" => target_id, "node" => node.merge("nodeId" => node_id) }
124
+ node_id = command("DOM.requestNode", objectId: object_id)["nodeId"]
125
+ node = command("DOM.describeNode", nodeId: node_id)["node"].merge("nodeId" => node_id)
126
+ { "target_id" => target_id, "node" => node }
125
127
  when "array"
126
- reduce_properties(response["objectId"], Array.new) do |memo, key, value|
128
+ reduce_props(object_id, []) do |memo, key, value|
127
129
  next(memo) unless (Integer(key) rescue nil)
128
- value = value["objectId"] ? handle(value, false) : value["value"]
130
+ value = value["objectId"] ? handle(value) : value["value"]
129
131
  memo.insert(key.to_i, value)
130
132
  end
131
133
  when "date"
@@ -133,61 +135,31 @@ module Capybara::Cuprite
133
135
  when "null"
134
136
  nil
135
137
  else
136
- reduce_properties(response["objectId"], Hash.new) do |memo, key, value|
137
- value = value["objectId"] ? handle(value, false) : value["value"]
138
+ reduce_props(object_id, {}) do |memo, key, value|
139
+ value = value["objectId"] ? handle(value) : value["value"]
138
140
  memo.merge(key => value)
139
141
  end
140
142
  end
141
143
  end
142
- ensure
143
- clean if cleanup
144
144
  end
145
145
 
146
- def reduce_properties(object_id, object)
147
- if visited?(object_id)
148
- "(cyclic structure)"
146
+ def reduce_props(object_id, to)
147
+ if cyclic?(object_id).dig("result", "value")
148
+ return "(cyclic structure)"
149
149
  else
150
- properties(object_id).reduce(object) do |memo, prop|
150
+ props = command("Runtime.getProperties", objectId: object_id)
151
+ props["result"].reduce(to) do |memo, prop|
151
152
  next(memo) unless prop["enumerable"]
152
153
  yield(memo, prop["name"], prop["value"])
153
154
  end
154
155
  end
155
156
  end
156
157
 
157
- def properties(object_id)
158
- command("Runtime.getProperties", objectId: object_id)["result"]
159
- end
160
-
161
- # Every `Runtime.getProperties` call on the same object returns new object
162
- # id each time {"objectId":"{\"injectedScriptId\":1,\"id\":1}"} and it's
163
- # impossible to check that two objects are actually equal. This workaround
164
- # does equality check only in JS runtime. `_cuprite` can be inavailable here
165
- # if page is about:blank for example.
166
- def visited?(object_id)
167
- expr = %Q(
168
- let object = arguments[0];
169
- let callback = arguments[1];
170
-
171
- if (window._cupriteVisitedObjects === undefined) {
172
- window._cupriteVisitedObjects = [];
173
- }
174
-
175
- let visited = window._cupriteVisitedObjects;
176
- if (visited.some(o => o === object)) {
177
- callback(true);
178
- } else {
179
- visited.push(object);
180
- callback(false);
181
- }
182
- )
183
-
184
- # FIXME: Is there a way we can use wait_time here?
185
- response = call(expr, 5, EVALUATE_ASYNC_OPTIONS, { "objectId" => object_id })
186
- handle(response, false)
187
- end
188
-
189
- def clean
190
- execute("delete window._cupriteVisitedObjects")
158
+ def cyclic?(object_id)
159
+ command("Runtime.callFunctionOn",
160
+ objectId: object_id,
161
+ returnByValue: true,
162
+ functionDeclaration: "function() { return _cuprite.isCyclic(this); }")
191
163
  end
192
164
  end
193
165
  end